// AUTO-GENERATED from agentic_teacher_sandbox/frontend/index.html by
// scratchpad/build_bt_assets.py (Checkpoint 1). Do not hand-edit; re-run the
// builder. IIFE-scoped: only window.BrainTraceApp escapes.
(function () {

  const { useState, useEffect, useRef, useMemo, useCallback } = React;
  // ForceGraph2D is acquired lazily inside GraphView (useState + inline loader),
  // NOT captured here at module-eval. Reason: in the PWA host the ~150KB lib
  // (react-force-graph-2d + d3-force) must load ONLY when the graph mounts, so
  // Test/Tutor-only visitors don't pay for it. A module-level const would capture
  // window.ForceGraph2D once (undefined in the lazy PWA case) and never update.
  // The standalone build still ships the <script> tag in <head>, so GraphView's
  // initializer resolves it synchronously on first render — zero behavior change.

  // [PWA port] Delegate to the PWA's JWT-bearing brainTraceFetch (exposed on
  // window.__brainTraceApiFetch). Preserves the original api(path, opts) contract:
  // returns parsed JSON, throws on !ok, prepends the Railway base + Authorization.
  async function api(path, opts = {}) {
    return window.__brainTraceApiFetch(path, opts);
  }

  // Mastery → color (matches the CSS palette + the plan's tonal palette)
  // Stage 3b: shared tier classification used by both `masteryColor` and the
  // answer-flow tier-transition detector. Keep the breakpoints in sync with
  // backend _BOARD_READY_MASTERY_THRESHOLD = 0.7 (planner.py:473) and the
  // MasteryTierTable rows. Returns one of:
  //   "unattempted" | "weak" | "developing" | "competent" | "mastered"
  function getTier(masteryEstimate, attempts) {
    if (!attempts || attempts === 0) return "unattempted";
    const m = masteryEstimate ?? 0;
    if (m < 0.3) return "weak";
    if (m < 0.6) return "developing";
    if (m < 0.7) return "competent";
    return "mastered";
  }
  // Tier ordering for "did the learner promote?" comparisons. mastered > competent > developing > weak > unattempted.
  const TIER_RANK = { unattempted: 0, weak: 1, developing: 2, competent: 3, mastered: 4 };
  // Title-case label for toast/recap copy.
  const TIER_LABEL = {
    unattempted: "Unattempted", weak: "Weak", developing: "Developing",
    competent: "Competent", mastered: "Mastered",
  };
  // Color → tier (used by masteryColor below + Stage 3c pulse accent).
  const TIER_COLOR = {
    unattempted: "#D6D6D2", weak: "#B5634C", developing: "#B5894C",
    competent: "#7A8C5C", mastered: "#2D5C4F",
  };

  function masteryColor(node) {
    return TIER_COLOR[getTier(node.mastery_estimate, node.attempts || 0)];
  }

  // Concept tags are stored lowercase (e.g. "subscapular nerve injury"); this
  // is the display-only humanizer (mirrors backend _humanize_concept_title).
  // Never use the result for matching/joins.
  function humanizeConceptTitle(tag) {
    if (!tag) return "";
    return String(tag).split(" ").map(w => w ? w[0].toUpperCase() + w.slice(1) : w).join(" ");
  }

  // Node radius — log-scaled by attempts, gently boosted by tested_frequency
  function nodeRadius(node) {
    const tfBoost = { high: 1.4, medium: 1.0, low: 0.75 }[node.tested_frequency || "medium"] || 1.0;
    const base = 4 + Math.log2((node.attempts || 0) + 1) * 1.6;
    return Math.min(14, Math.max(4, base * tfBoost));
  }

  // Mastery bar (ported from the v80 PWA node panel). Colored zone WIDTHS match the
  // real tier thresholds (weak 0-30, developing 30-60, competent 60-70, mastered
  // 70-100) so the caret — placed at the true mastery % — always lands in the zone
  // whose color === getTier(...). (Equal fifths would mis-color the caret.) Reuses
  // the existing getTier + TIER_COLOR. An unattempted node renders a muted no-data
  // state with no caret.
  function MasteryBar({ mastery, attempts }) {
    const attempted = (attempts ?? 0) > 0;
    const pct = Math.max(0, Math.min(100, Math.round((Number(mastery) || 0) * 100)));
    const tier = getTier(mastery, attempts);
    const ZONES = [
      { key: "weak",       w: 30 },   // 0–30%
      { key: "developing", w: 30 },   // 30–60%
      { key: "competent",  w: 10 },   // 60–70%
      { key: "mastered",   w: 30 },   // 70–100%
    ];
    const labelStyle = { fontSize: 9, color: "var(--muted)", fontFamily: "'DM Mono', monospace" };
    return (
      <div>
        {/* caret + % (attempted only); clamped to [0,100] so it never overflows */}
        <div style={{ position: "relative", height: 14, marginBottom: 3 }}>
          {attempted && (
            <div style={{
              position: "absolute", left: pct + "%", top: 0, transform: "translateX(-50%)",
              display: "flex", flexDirection: "column", alignItems: "center", whiteSpace: "nowrap",
              transition: "left 0.5s var(--easing)",
            }}>
              <span style={{
                fontSize: 10, fontFamily: "'DM Mono', monospace", fontWeight: 600,
                color: TIER_COLOR[tier], lineHeight: 1,
              }}>{pct}%</span>
              <span style={{
                width: 0, height: 0, marginTop: 2,
                borderLeft: "4px solid transparent", borderRight: "4px solid transparent",
                borderTop: "5px solid " + TIER_COLOR[tier],
              }}/>
            </div>
          )}
        </div>
        {/* the bar */}
        <div style={{
          display: "flex", height: 8, borderRadius: 9999,
          overflow: "hidden", border: "1px solid var(--rule)",
        }}>
          {attempted
            ? ZONES.map(z => (
                <div key={z.key} style={{ width: z.w + "%", background: TIER_COLOR[z.key] }}/>
              ))
            : <div style={{ width: "100%", background: TIER_COLOR.unattempted }}/>}
        </div>
        {/* endpoint labels */}
        {attempted ? (
          <div style={{ display: "flex", justifyContent: "space-between", marginTop: 3 }}>
            <span style={labelStyle}>0%</span>
            <span style={labelStyle}>100% · Mastered</span>
          </div>
        ) : (
          <div style={{ marginTop: 3 }}><span style={labelStyle}>Untouched — no attempts yet</span></div>
        )}
      </div>
    );
  }

  // ─────────── Root app ───────────
  function App() {
    const [pools, setPools] = useState([]);
    const [structure, setStructure] = useState(null);   // {nodes, edges}
    const [graphState, setGraphState] = useState(null); // {per_node: {...}}
    const [session, setSession] = useState(null);       // lazy: created on first study click
    const [studyingNodeId, setStudyingNodeId] = useState(null);
    const [studyingNodeInfo, setStudyingNodeInfo] = useState(null);
    const [interaction, setInteraction] = useState(null);
    const [answerResult, setAnswerResult] = useState(null);
    // Route study surface (Slice 1): full-screen tutor-styled walk through the active
    // route (positions 1..N), reusing the session/answer plumbing. Distinct from the
    // node-click StudyOverlay path, which stays intact.
    const [routeStudyOpen, setRouteStudyOpen] = useState(false);
    const [routePositions, setRoutePositions] = useState([]);
    const [currentPosition, setCurrentPosition] = useState(0);
    // Phase 1 (Alec's bug): per-position review cache, keyed by route index →
    // { interaction, answerResult, selectedIdx }. Rail navigation to an
    // already-visited position restores this instead of re-drawing — /next
    // excludes session-answered hashes, so a re-draw on an answered position
    // can only serve a DIFFERENT question (or node_exhausted), never the
    // original. Reset per walk.
    const [routeQuestionCache, setRouteQuestionCache] = useState({});
    // Suppresses the display-triggered prefetch effects for one commit when a
    // cached position is restored (a review is not a new draw).
    const reviewRestoreRef = useRef(false);
    // Session length — how many route positions the user walks per sitting (5/10/15/20).
    // Pure UX: it only slices/extends the walk; the working-set N (ACTIVE_CONCEPT_SET_SIZE)
    // and the mastery/spacing algorithm are never touched. Persisted to localStorage.
    const [sessionLen, setSessionLen] = useState(() => {
      try {
        const v = parseInt(localStorage.getItem("bt_session_len") || "10", 10);
        return [5, 10, 15, 20].includes(v) ? v : 10;
      } catch (_) { return 10; }
    });
    function updateSessionLen(n) {
      setSessionLen(n);
      try { localStorage.setItem("bt_session_len", String(n)); } catch (_) {}
      // v107: the picker drives k end-to-end. Refetch immediately so the graph
      // beads, the animated route path, and the stats strip all reflect the
      // chosen length without waiting for the next study action. Served from
      // the backend route cache (v106), so this is a cheap read.
      api("/route/current?k=" + n)
        .then(rm => {
          setRouteMetrics(rm);
          _btSaveRouteMetrics(rm);
          _btSaveRoutePaint(rm && rm.active_route);
          _btSaveRouteStep(rm && rm.active_route && rm.active_route[0]);
        })
        .catch(() => {});
    }
    // Slice 2/3/4 live-mastery rail state:
    //   railMastery   — node_id → {mastery_estimate, attempts}; seeded from graphState
    //                   on open, live-patched via one targeted /info fetch per answer.
    //   pulseNodeId   — the just-answered node; drives an 850ms pulse on its rail row
    //                   + mini-map circle (set on EVERY answer). Auto-clears.
    //   railFlashNodeId — set only when the answered node LEVELS UP (tier crossing);
    //                   drives a 1.2s green border flash on that rail row. Auto-clears.
    const [railMastery, setRailMastery] = useState({});
    const [pulseNodeId, setPulseNodeId] = useState(null);
    const [railFlashNodeId, setRailFlashNodeId] = useState(null);
    const [routeMetrics, setRouteMetrics] = useState(null);
    // Display-only fallback for the header stats strip: last-known SCALAR metrics from
    // localStorage, read once at mount so the tiles paint instantly on repeat opens.
    // routeMetrics (the fresh fetch) always wins for route LOGIC; this only fills the
    // strip until it lands. Never used for active_route / cinematic targeting.
    const [cachedStats] = useState(() => _btLoadRouteMetrics());
    // Paint-only caches (NOT logic): cached per-node mastery (COLORS) + cached route
    // positions (BADGE numbers), read once at mount so a repeat open paints a fully-
    // colored, fully-numbered graph on frame 1 instead of gray → color → number. Fresh
    // /graph/state + /route/current always override these. cachedRoutePaint is a plain
    // {node_id:[positions]} map that feeds ONLY the badge useMemo — never merged into
    // routeMetrics, so cinematic/decay/start logic still use fresh route data (v89 rule).
    const [cachedGraphState] = useState(() => _btLoadGraphState());
    const [cachedRoutePaint] = useState(() => _btLoadRoutePaint());
    // First route step (display + node_id) for instant CTA render on repeat opens.
    const [cachedRouteStep] = useState(() => _btLoadRouteStep());
    // Reveal-coalesce "request finished" flags (set in .catch). Let an ERRORED fetch
    // still unblock the reveal instead of hanging the canvas at opacity 0 forever.
    const [graphStateSettled, setGraphStateSettled] = useState(false);
    const [routeSettled, setRouteSettled] = useState(false);
    const [toast, setToast] = useState(null);
    const [error, setError] = useState(null);
    // Stage 3b/c/d: tier-transition celebration UX state.
    //   - tierCrossingsRef: ephemeral Map<node_id, timestamp_ms> consumed by
    //     the GraphView's nodeCanvasOverlay to draw an expanding-ring pulse
    //     on the just-promoted node. Entries auto-expire after 800ms.
    //   - sessionTierCrossings: array of {concept, after_tier, fast_path}
    //     accumulated across the session for the recap card on session-end.
    const tierCrossingsRef = useRef(new Map());
    const [sessionTierCrossings, setSessionTierCrossings] = useState([]);
    // Bump on every pulse-add so GraphView re-renders and picks it up.
    const [pulseTick, setPulseTick] = useState(0);
    const [recapCardOpen, setRecapCardOpen] = useState(false);

    // Day 4 Slice C: cold-start CTA + diagnostic exam state
    const [coldStartVisible, setColdStartVisible] = useState(false);
    const [diagnosticSession, setDiagnosticSession] = useState(null);
    const [diagnosticQuestion, setDiagnosticQuestion] = useState(null);
    const [diagnosticAnswerResult, setDiagnosticAnswerResult] = useState(null);
    const [diagnosticResults, setDiagnosticResults] = useState(null);
    const [diagnosticExamsCompleted, setDiagnosticExamsCompleted] = useState(0);

    // Day 4 Slice D4: walkthrough state for stuck (exhausted-and-weak) nodes
    const [walkthroughText, setWalkthroughText] = useState(null);
    const [walkthroughLoading, setWalkthroughLoading] = useState(false);
    const [walkthroughError, setWalkthroughError] = useState(null);

    // Day 4 Slice D-polish: View by Section filter (null = all sections visible)
    const [activeSection, setActiveSection] = useState(null);
    // Day 4 Slice D-polish: onboarding tutorial step (0 = hidden, 1..6 = active)
    const [tutorialStep, setTutorialStep] = useState(0);
    // "Don't show this again" checkbox on the final tour card → persistent
    // (localStorage) suppress flag, distinct from the per-session tutorialSeenAt.
    const [tourDontShowAgain, setTourDontShowAgain] = useState(false);
    // Slice D-polish v4 (construction layer): per-user authoring state.
    //   construction.annotations: { node_id: {content_text, updated_at} }
    //   construction.attention:   { node_id: {flagged_at, note} }
    //   construction.personal_edges: [{id, source_id, target_id, label, created_at}]
    const [construction, setConstruction] = useState({
      annotations: {}, attention: {}, personal_edges: [],
    });
    // Click-two-nodes-to-link mode for personal-edge authoring. When non-null,
    // the next graph click selects the source; the click after that creates the edge.
    const [linkingFromNodeId, setLinkingFromNodeId] = useState(null);
    // Slice D-polish v4: right-click context menu on graph nodes — surfaces the
    // three construction CTAs (note, attention, link) without forcing a study-panel open.
    const [contextMenu, setContextMenu] = useState(null);  // {nodeId, title, x, y} | null
    // Right-click on a PERSONAL edge opens a small "delete this connection" menu.
    const [linkContextMenu, setLinkContextMenu] = useState(null);  // {edgeId, title, x, y} | null
    // Day 5: catch-up banner dismissal (per-session — banner re-fires on next page load if still due)
    const [catchupDismissed, setCatchupDismissed] = useState(false);
    // Day 5: teach-back modal state
    //   teachBackOpen     — node_id when the modal is up; null otherwise
    //   teachBackContext  — {nodeTitle, questionHash, suggestedTrigger}
    //   teachBackResult   — full TeachBackResult payload after grading
    //   teachBackBusy     — request in flight
    //   teachBackError    — string, or {expertise_reversal: true, currentMastery, floor}
    const [teachBackOpen, setTeachBackOpen] = useState(null);
    const [teachBackContext, setTeachBackContext] = useState(null);
    const [teachBackResult, setTeachBackResult] = useState(null);
    const [teachBackBusy, setTeachBackBusy] = useState(false);
    const [teachBackError, setTeachBackError] = useState(null);
    // Slice D-polish v4: Rapid Review modal state.
    //   rapidReviewOpen — modal visible
    //   rapidReviewStatus — eligibility + counts (poll on open)
    //   rapidReviewResult — the freshly-generated packet (preview + download_url)
    //   rapidReviewBusy — generation in flight
    const [rapidReviewOpen, setRapidReviewOpen] = useState(false);
    const [rapidReviewStatus, setRapidReviewStatus] = useState(null);
    const [rapidReviewResult, setRapidReviewResult] = useState(null);
    const [rapidReviewBusy, setRapidReviewBusy] = useState(false);
    const [rapidReviewError, setRapidReviewError] = useState(null);
    // Slice D-polish v3: during Card 2 the graph cycles through PSITE sections
    // to show the cluster structure. null = no cycling (default).
    const [clusterCycleSection, setClusterCycleSection] = useState(null);

    // Reset Brain Trace modal state. resetModalOpen = visible; resetBusy = wipe in flight;
    // resetReport = the {deleted, preserved} payload returned by /learner/reset (rendered
    // briefly before reload as receipt + reassurance that the wipe actually happened).
    const [resetModalOpen, setResetModalOpen] = useState(false);
    const [resetBusy, setResetBusy] = useState(false);
    const [resetReport, setResetReport] = useState(null);

    // Mobile chrome (≤768px): the always-open mastery legend collapses behind a
    // "Legend" chip and the footer reflows into a stacked bottom bar. Desktop
    // rendering is byte-identical to before — every mobile branch is additive.
    const isMobileView = useViewportIsMobile();
    const [legendOpen, setLegendOpen] = useState(false);

    // Flashcards modal retired: Flash Cards is now a first-class PWA sidebar
    // tab (decks + per-card spaced scheduling). The footer CTA and node-panel
    // action navigate there via goToFlashCardsTab().

    // Prefetch ref — same-node prefetch (display-triggered, see Day 3.5 redesign)
    const prefetchRef = useRef(null);
    // Latency: per-node cache of /route/node/{id}/info (side-effect-free) so a hover can
    // warm it and the click reuses it — taking the node-info fetch off the click path.
    // Invalidated for a node after any answer on it (mastery/eligibility shift, see
    // submitAnswer). clickTokenRef guards the async click chain: only the most recent
    // click may commit results (prevents a stale /next from a closed panel writing state).
    const nodeInfoCacheRef = useRef(new Map());
    const clickTokenRef = useRef(0);

    // Initial load: pool list, graph structure + state, current route, learner state
    useEffect(() => {
      // Each fetch is INDEPENDENT so the middle renders progressively instead of
      // blocking on the slowest call (the full-planner /route/current?k=10). The map
      // + section tabs appear the instant /graph/structure lands (both gate on
      // `structure` alone and tolerate routeMetrics=null); node colors fill in with
      // /graph/state; the stats strip + Start CTA fill in with /route/current.

      // Structure: stale-while-revalidate from localStorage so repeat opens paint the
      // map with zero network wait; swap to fresh only if the topology changed.
      const cachedStruct = _btLoadStructure();
      if (cachedStruct) setStructure(cachedStruct);
      api("/graph/structure")
        .then(struct => {
          setStructure(prev => (prev && _btTopoSig(prev) === _btTopoSig(struct)) ? prev : struct);
          _btSaveStructure(struct);
        })
        .catch(e => { if (!cachedStruct) setError(e.message); });  // fatal only if nothing to show

      api("/graph/state")
        .then(gs => { setGraphState(gs); _btSaveGraphState(gs); })
        .catch(() => setGraphStateSettled(true));  // unblock reveal even on error
      api("/route/current?k=" + sessionLen)
        .then(rm => { setRouteMetrics(rm); _btSaveRouteMetrics(rm); _btSaveRoutePaint(rm && rm.active_route); _btSaveRouteStep(rm && rm.active_route && rm.active_route[0]); })
        .catch(() => setRouteSettled(true));  // unblock reveal even on error
      api("/health").then(h => setPools(h.pools || [])).catch(() => {});
      api("/construction/overview")
        .then(cons => setConstruction({
          annotations: cons.annotations || {},
          attention: cons.attention || {},
          personal_edges: cons.personal_edges || [],
        }))
        .catch(() => {});
      api("/learner/state")
        .then(learner => {
          setDiagnosticExamsCompleted(learner.diagnostic_exams_completed || 0);
          // Cold-start: show CTA if no answers ever AND not dismissed this session.
          const dismissed = sessionStorage.getItem("brainTrace.coldStartDismissed") === "1";
          const isColdStart = (learner.recent_answers?.length ?? 0) === 0;
          const tutorialSeen = sessionStorage.getItem("brainTrace.tutorialSeenAt") !== null;
          const tutorialDismissedForever = localStorage.getItem("brainTrace.tourDismissedForever") === "1";
          if (isColdStart && !dismissed) {
            setColdStartVisible(true);  // tutorial deferred until dismissed (avoid double-modal)
          } else if (!tutorialSeen && !tutorialDismissedForever) {
            setTutorialStep(1);
          }
        })
        .catch(() => {});

    }, []);

    // Refresh learner-state-derived counters whenever a session ends or state mutates.
    async function refreshLearnerCounters() {
      try {
        const learner = await api("/learner/state");
        setDiagnosticExamsCompleted(learner.diagnostic_exams_completed || 0);
      } catch (_) {}
    }

    // Auto-dismiss toasts after 3s
    useEffect(() => {
      if (!toast) return;
      const t = setTimeout(() => setToast(null), 3000);
      return () => clearTimeout(t);
    }, [toast]);

    // Slice D-polish v3: during tutorial Card 2 ("This is your knowledge map"),
    // cycle the graph through each PSITE section's cluster — ~1.8s per section,
    // dimming the rest. Gives the user a visual proof that the map is
    // organized into the five clinical domains. Effect runs only when the user
    // is on step 2 of the tour; entering or leaving step 2 re-arms or cleans up.
    useEffect(() => {
      if (tutorialStep !== 2 || !structure) {
        setClusterCycleSection(null);
        return;
      }
      // Steven-defined explicit clinical sequence (NOT alphabetical).
      // Any vault-expansion section not in this list appends to the end.
      const EXPLICIT_SECTION_ORDER = [
        "breast-and-cosmetic",
        "hand-and-extremities",
        "core-surgical-principles",
        "craniomaxillofacial",
        "comprehensive-integument",
      ];
      const present = new Set(
        (structure.nodes || []).map(n => n.psite_section).filter(Boolean)
      );
      const sections = EXPLICIT_SECTION_ORDER.filter(s => present.has(s));
      for (const s of present) if (!sections.includes(s)) sections.push(s);
      if (!sections.length) return;
      let i = 0;
      setClusterCycleSection(sections[0]);
      const interval = setInterval(() => {
        i = (i + 1) % (sections.length + 1);  /* +1 for a brief "all sections" pause */
        setClusterCycleSection(i < sections.length ? sections[i] : null);
      }, 2600);  /* ~700ms camera + ~1900ms dwell */
      return () => { clearInterval(interval); setClusterCycleSection(null); };
    }, [tutorialStep, structure]);

    // Display-triggered prefetch (Day 3.5): when a question loads, fetch the next
    // one in the background. Scoped to the currently studying node.
    useEffect(() => {
      if (reviewRestoreRef.current) return;  // cached review restore — not a new draw
      if (!interaction?.question?.content_hash || !session || !studyingNodeId) return;
      const currentHash = interaction.question.content_hash;
      const params = new URLSearchParams({ exclude: currentHash, node_id: studyingNodeId });
      prefetchRef.current = api(
        `/session/${session.session_id}/next?${params}`,
        { method: "POST" },
      ).catch(e => ({ __prefetchError: e }));
    }, [interaction, session, studyingNodeId]);

    // v105: route-POSITION-aware prefetch — the same-node prefetch above never
    // helped the route walk, where "Next topic →" moves to a DIFFERENT node and
    // paid info + /next round-trips on the critical path. While the learner
    // reads the feedback panel (triggered on answerResult, so the next
    // question is selected against POST-answer mastery, not a stale snapshot),
    // warm the next position's node info (prefetchNodeInfo's 30s cache) and
    // its /next payload. openRoutePosition→handleNodeClick consumes the
    // warmed payload when the node matches — zero network on advance.
    const routeNextPrefetchRef = useRef(null);
    useEffect(() => {
      if (reviewRestoreRef.current) return;  // cached review restore — not a fresh answer event
      if (!routeStudyOpen || !session || !answerResult) return;
      const np = routePositions[currentPosition + 1];
      if (!np || !np.node_id) return;
      prefetchNodeInfo(np.node_id);
      routeNextPrefetchRef.current = {
        node_id: np.node_id,
        nextP: api(
          `/session/${session.session_id}/next?node_id=${encodeURIComponent(np.node_id)}`,
          { method: "POST" },
        ).catch(e => ({ __prefetchError: e })),
      };
    }, [answerResult, session, routeStudyOpen, currentPosition, routePositions]);

    // Phase 1 (Alec's bug): keep the per-position review cache current. Watches
    // every interaction/answer mutation (draw, submit, async analyzer patch) so
    // the cached entry always carries the freshest analysis. node_exhausted
    // payloads (no .question) are never cached — an exhausted position stays
    // re-fetchable/skippable rather than replaying a dead end.
    useEffect(() => {
      if (!routeStudyOpen || !interaction || !interaction.question) return;
      setRouteQuestionCache(prev => {
        const cur = prev[currentPosition];
        if (cur && cur.interaction === interaction && cur.answerResult === answerResult) return prev;
        return { ...prev, [currentPosition]: { ...cur, interaction, answerResult } };
      });
    }, [routeStudyOpen, interaction, answerResult, currentPosition]);

    // Runs after the effects above within the same commit (definition order),
    // so a restore suppresses the prefetches exactly once and never leaks the
    // flag into the next real draw.
    useEffect(() => { reviewRestoreRef.current = false; });

    // v107: pre-session position-1 warm-up. The instant the route lands (or
    // changes), warm node info for position 1 so the CTA click renders the
    // study surface header with zero info round-trip. The question itself
    // still needs a session (created on CTA click) — that pair of calls is
    // the only remaining network on the first-question path.
    useEffect(() => {
      if (session || routeStudyOpen) return;
      const p1 = routeMetrics?.active_route?.[0];
      if (p1 && p1.node_id) prefetchNodeInfo(p1.node_id);
    }, [routeMetrics, session, routeStudyOpen]);

    // Budget-exhausted visibility: surface the paused-AI state once per
    // SESSION (keyed by session_id — a new session's cap exhaustion re-fires)
    // instead of failing silently (mastery still updates deterministically).
    const budgetToastShownRef = useRef(null);

    // Async analyzer poll (Day 3.5)
    useEffect(() => {
      if (!answerResult || !session) return;
      if (answerResult.analysis?.status !== "pending") return;
      const hashAtStart = interaction?.question?.content_hash;
      if (!hashAtStart) return;
      let cancelled = false;
      const start = Date.now();
      const tick = async () => {
        if (cancelled) return;
        if (Date.now() - start > 8000) return;
        try {
          const a = await api(`/session/${session.session_id}/analysis/${hashAtStart}`);
          if (cancelled) return;
          if (a.status === "complete" || a.status === "error") {
            setAnswerResult(prev => {
              if (!prev || interaction?.question?.content_hash !== hashAtStart) return prev;
              return { ...prev, analysis: a.status === "complete"
                ? { status: "complete", ...a.analysis }
                : { status: "error", note: a.error } };
            });
            // Stage 3b/c: tier-transition detection. Iterate the analyzer's
            // mastery_changes (per-concept m_before/m_after) and the
            // fast_path_fired_concepts list; for each concept whose tier
            // index increased OR which crossed via the spaced fast-path,
            // pulse the studying node, fire a toast, and append to the
            // session-recap accumulator.
            if (a.status === "complete" && a.analysis) {
              if (a.analysis.category === "budget_exhausted"
                  && budgetToastShownRef.current !== session.session_id) {
                budgetToastShownRef.current = session.session_id;
                setToast({
                  text: "AI analysis paused — LLM budget reached. Mastery still updates deterministically.",
                  warn: true,
                });
              }
              const changes = a.analysis.mastery_changes || [];
              const fastPath = new Set(a.analysis.fast_path_fired_concepts || []);
              const crossings = [];
              for (const ch of changes) {
                const before = getTier(ch.mastery_before, 1);  // attempts > 0 by definition (we just answered)
                const after  = getTier(ch.mastery_after, 1);
                const tierUp = TIER_RANK[after] > TIER_RANK[before];
                const fastPathFired = fastPath.has(ch.concept);
                if (tierUp || fastPathFired) {
                  crossings.push({
                    concept: ch.concept,
                    after_tier: after,
                    fast_path: fastPathFired,
                  });
                }
              }
              if (crossings.length > 0) {
                // Pulse the studying node — that's the canonical "node the
                // answer was about." Multi-concept questions span concepts
                // that map to other nodes; for v1 we pulse only the studied
                // node (most relevant cue, no flicker on adjacent nodes).
                if (studyingNodeId) {
                  tierCrossingsRef.current.set(studyingNodeId, performance.now());
                  setPulseTick(t => t + 1);
                  // Auto-expire the entry after the animation duration.
                  setTimeout(() => {
                    tierCrossingsRef.current.delete(studyingNodeId);
                    setPulseTick(t => t + 1);
                  }, 850);
                  // Route rail: green border flash on the leveled node's row.
                  const leveled = studyingNodeId;
                  setRailFlashNodeId(leveled);
                  setTimeout(() => setRailFlashNodeId(f => (f === leveled ? null : f)), 1200);
                }
                // Toast: highlight the highest-tier crossing in this batch.
                // Multi-concept tier-ups in one answer get one toast (the best
                // one), avoiding a stack of overlapping toasts.
                const top = crossings.reduce((best, c) =>
                  TIER_RANK[c.after_tier] > TIER_RANK[best.after_tier] ? c : best
                );
                const arrow = top.fast_path ? " ✦" : " ↑";
                setToast({
                  text: `${humanizeConceptTitle(top.concept)} → ${TIER_LABEL[top.after_tier]}${arrow}`,
                });
                // Accumulate for the per-session recap card (Stage 3d).
                setSessionTierCrossings(prev => [...prev, ...crossings]);
              }
              // Route study surface (Slice 2/3): live-patch the answered node's rail
              // bar + pulse its rail row / mini-map circle. One targeted /info fetch —
              // never a /graph/state blob. Gated on routeStudyOpen so the node-click
              // StudyOverlay path never pays for it.
              if (routeStudyOpen && studyingNodeId) {
                const answered = studyingNodeId;
                setPulseNodeId(answered);
                setTimeout(() => setPulseNodeId(p => (p === answered ? null : p)), 850);
                // Evict before refetching: submitAnswer's immediate refresh
                // (v103) re-cached a 30s-fresh entry with the PRE-analyzer
                // mastery — without this delete the analyzer's correction
                // delta would never reach the rail.
                nodeInfoCacheRef.current.delete(answered);
                prefetchNodeInfo(answered).then(info => {
                  if (cancelled) return;
                  setRailMastery(m => ({ ...m, [answered]: {
                    mastery_estimate: info.mastery_estimate, attempts: info.attempts,
                  } }));
                }).catch(() => {});
              }
            }
            return;
          }
        } catch (_) {}
        setTimeout(tick, 700);
      };
      setTimeout(tick, 700);
      return () => { cancelled = true; };
    }, [answerResult, session, interaction]);

    // Lazily create a session if needed
    async function ensureSession() {
      if (session) return session;
      if (!pools.length) throw new Error("No question pools loaded");
      const s = await api("/session/start", {
        method: "POST",
        body: JSON.stringify({ pool_path: pools[0].path, mode: "agentic" }),
      });
      setSession(s);
      return s;
    }

    // Latency: create the study session EAGERLY once pools are known, so the
    // first "Start your study adventure" click doesn't pay the /session/start
    // round-trip serially before /next can fire. A session row is cheap and
    // spends nothing (the cost cap meters LLM calls, not sessions); errors
    // here are ignored — the on-click ensureSession() path retries.
    useEffect(() => {
      if (pools.length && !session) {
        ensureSession().catch(() => {});
      }
    }, [pools]);

    // Latency: fetch /route/node/{id}/info (side-effect-free) on hover so the click
    // doesn't wait on it. Dedupes via the cache (stores the in-flight Promise, then the
    // resolved value). Errors evict the entry so a later hover/click can retry.
    function prefetchNodeInfo(nodeId) {
      if (!nodeId) return Promise.reject(new Error("no node id"));
      const cache = nodeInfoCacheRef.current;
      // 30s freshness window bounds staleness from ANY mastery-changing modality
      // (teach-back, flashcards), on top of the explicit delete after an MCQ answer.
      const FRESH_MS = 30000;
      const hit = cache.get(nodeId);
      if (hit && (performance.now() - hit.ts) < FRESH_MS) return hit.p;
      const entry = { ts: performance.now(), p: null };
      entry.p = api(`/route/node/${encodeURIComponent(nodeId)}/info`)
        .catch((e) => { if (cache.get(nodeId) === entry) cache.delete(nodeId); throw e; });
      cache.set(nodeId, entry);
      return entry.p;
    }

    // Click a graph node → open the study overlay IMMEDIATELY, then fill it in.
    // While linking-mode is active, a click instead completes a personal edge.
    async function handleNodeClick(node) {
      if (!node || !node.id) return;
      if (linkingFromNodeId) {
        await completePersonalEdge(node.id);
        return;
      }
      // Race guard: only the most recent click may commit async results (the user can
      // close a panel mid-fetch; a stale /next must not write onto a later open).
      const myToken = ++clickTokenRef.current;
      const isCurrent = () => clickTokenRef.current === myToken;

      // Perceived-latency fix: render the panel on click. Seed a provisional header from
      // the clicked node so the title + mastery show at once; the full /info overwrites
      // it when it lands. has_questions:true keeps the stuck card from flashing before
      // /info is known. StudyOverlay degrades gracefully on partial info.
      setWalkthroughText(null);
      setWalkthroughError(null);
      setWalkthroughLoading(false);
      setStudyingNodeInfo({
        title: node.name,
        psite_section: node.section,
        mastery_estimate: node.mastery_estimate ?? 0,
        attempts: node.attempts || 0,
        tested_frequency: node.tested_frequency,
        difficulty: node.difficulty,
        has_questions: true,
        _provisional: true,
      });
      setStudyingNodeId(node.id);
      prefetchRef.current = null;
      setInteraction(null);
      setAnswerResult(null);

      let infoReady = false;
      try {
        // /info (often already hover-prefetched) and the session are independent → run
        // them together so the first-click /session/start overlaps the info fetch.
        const infoP = prefetchNodeInfo(node.id);
        const sessP = ensureSession();

        const info = await infoP;
        if (!isCurrent()) return;  // user closed/reopened — drop stale result
        infoReady = true;
        setStudyingNodeInfo(info);

        // No servable questions (topic cleared / vault gap): show the stuck card, no /next.
        if (!info.has_questions) {
          setInteraction(null);
          return;
        }

        const sess = await sessP;
        // v105: consume the route-position prefetch when it targeted this
        // node — the /next payload was warmed while the learner read the
        // previous feedback panel, so advancing costs zero network here.
        let nxt = null;
        const rp = routeNextPrefetchRef.current;
        if (rp && rp.node_id === node.id && rp.nextP) {
          routeNextPrefetchRef.current = null;
          const pre = await rp.nextP;
          if (pre && !pre.__prefetchError) nxt = pre;
        }
        if (!nxt) {
          nxt = await api(
            `/session/${sess.session_id}/next?node_id=${encodeURIComponent(node.id)}`,
            { method: "POST" },
          );
        }
        if (!isCurrent()) return;
        setInteraction(nxt);
      } catch (e) {
        if (!isCurrent()) return;
        setError(e.message);
        // If /info itself failed we have nothing to show, and the panel (opened
        // optimistically on click) would otherwise hang on the provisional placeholder
        // forever — so close it (the original code never opened the panel on an /info
        // error). A /next failure happens after real info is shown, so leave the panel
        // open in that case, matching prior behavior.
        if (!infoReady) {
          setStudyingNodeId(null);
          setStudyingNodeInfo(null);
          setInteraction(null);
        }
      }
    }

    // Day 4 Slice D4: "Teach me this" — fetch Sonnet walkthrough for stuck node
    async function fetchWalkthrough() {
      if (!studyingNodeId) return;
      setWalkthroughLoading(true);
      setWalkthroughError(null);
      try {
        // Sonnet-backed — well beyond the 30s default deadline.
        const r = await api(`/node/${encodeURIComponent(studyingNodeId)}/walkthrough`, { method: "POST", timeoutMs: 120000 });
        setWalkthroughText(r.walkthrough_text);
      } catch (e) {
        setWalkthroughError(e.message);
      } finally {
        setWalkthroughLoading(false);
      }
    }

    // Day 4 Slice D4: "Study a related concept" — find next non-stuck node on
    // the active route and open it. v1: simple sibling pick (next route position
    // with eligible_question_count > 0); v2 could use graph adjacency.
    async function studyRelatedConcept() {
      if (!routeMetrics?.active_route?.length) {
        setToast({ text: "No related concepts available — try the diagnostic exam.", warn: true });
        return;
      }
      const candidate = routeMetrics.active_route.find(
        (n) => n.node_id !== studyingNodeId
          && (n.eligible_question_count ?? 0) > 0
      );
      if (!candidate) {
        setToast({ text: "No related route concepts have available questions right now.", warn: true });
        return;
      }
      // Close current overlay then open new one (handleNodeClick handles the rest)
      await handleNodeClick({ id: candidate.node_id });
    }

    function markAsNeedsWork() {
      // v1: just toast; v2 would persist a `pinned_for_vault_expansion` flag
      // that surfaces in /graph/coverage and the writers' work queue.
      setToast({
        text: `"${studyingNodeInfo?.title}" flagged for vault expansion. (v1 stub — full persistence in v2.)`,
      });
    }

    // ── Slice D-polish v4: construction-layer handlers ──────────────────────
    // Annotations, needs-attention flags, and personal edges are the v1
    // surface that earns the "construction beats consumption" claim.
    async function refreshConstruction() {
      try {
        const cons = await api("/construction/overview");
        setConstruction({
          annotations: cons.annotations || {},
          attention: cons.attention || {},
          personal_edges: cons.personal_edges || [],
        });
      } catch (_) {}
    }

    async function saveAnnotation(nodeId, contentText) {
      if (!nodeId) return;
      try {
        const r = await api(`/node/${encodeURIComponent(nodeId)}/annotation`, {
          method: "PUT",
          body: JSON.stringify({ content_text: contentText }),
        });
        setConstruction((prev) => {
          const annotations = { ...prev.annotations };
          if (r.content_text) {
            annotations[nodeId] = {
              content_text: r.content_text, updated_at: r.updated_at,
            };
          } else {
            delete annotations[nodeId];
          }
          return { ...prev, annotations };
        });
        setToast({ text: r.content_text ? "Note saved." : "Note cleared." });
      } catch (e) { setError(e.message); }
    }

    async function toggleAttention(nodeId) {
      if (!nodeId) return;
      const isFlagged = !!construction.attention[nodeId];
      try {
        if (isFlagged) {
          await api(`/node/${encodeURIComponent(nodeId)}/attention`, { method: "DELETE" });
          setConstruction((prev) => {
            const attention = { ...prev.attention };
            delete attention[nodeId];
            return { ...prev, attention };
          });
          setToast({ text: "Attention flag cleared." });
        } else {
          const r = await api(`/node/${encodeURIComponent(nodeId)}/attention`, {
            method: "POST",
            body: JSON.stringify({ note: null }),
          });
          setConstruction((prev) => ({
            ...prev,
            attention: {
              ...prev.attention,
              [nodeId]: { flagged_at: r.flagged_at, note: r.note },
            },
          }));
          setToast({ text: "Flagged — this node will surface on your route." });
        }
        // Refresh route so the planner's attention-bonus is reflected immediately
        try {
          const route = await api("/route/current?k=" + sessionLen);
          setRouteMetrics(route);
        } catch (_) {}
      } catch (e) { setError(e.message); }
    }

    function startLinking(fromNodeId) {
      setLinkingFromNodeId(fromNodeId);
      setToast({
        text: "Linking mode — click another node on the graph to draw a personal edge.",
      });
    }

    function cancelLinking() {
      setLinkingFromNodeId(null);
    }

    async function completePersonalEdge(targetNodeId) {
      const sourceId = linkingFromNodeId;
      if (!sourceId || !targetNodeId || sourceId === targetNodeId) {
        setLinkingFromNodeId(null);
        return;
      }
      try {
        const r = await api("/personal-edge", {
          method: "POST",
          body: JSON.stringify({ source_id: sourceId, target_id: targetNodeId }),
        });
        if (r.duplicate) {
          setToast({ text: "That personal edge already exists.", warn: true });
        } else {
          setToast({ text: "Personal edge drawn." });
        }
        setConstruction((prev) => {
          if (r.duplicate) return prev;
          return { ...prev, personal_edges: [r, ...prev.personal_edges] };
        });
      } catch (e) {
        setError(e.message);
      } finally {
        setLinkingFromNodeId(null);
      }
    }

    // ── Slice D-polish v4: Rapid Review handlers ───────────────────────────
    async function openRapidReview() {
      setRapidReviewError(null);
      setRapidReviewResult(null);
      setRapidReviewOpen(true);
      try {
        const s = await api("/rapid-review/status");
        setRapidReviewStatus(s);
      } catch (e) { setRapidReviewError(e.message); }
    }
    function closeRapidReview() {
      setRapidReviewOpen(false);
      setRapidReviewBusy(false);
      // keep last result + status; reload on next open
    }
    async function generateRapidReview() {
      setRapidReviewBusy(true);
      setRapidReviewError(null);
      try {
        // LLM generation — well beyond the 30s default deadline.
        const r = await api("/rapid-review/generate", { method: "POST", timeoutMs: 180000 });
        setRapidReviewResult(r);
        // refresh status so cooldown countdown is visible immediately
        try {
          const s = await api("/rapid-review/status");
          setRapidReviewStatus(s);
        } catch (_) {}
      } catch (e) {
        setRapidReviewError(e.message);
      } finally {
        setRapidReviewBusy(false);
      }
    }

    // ── Flash Cards tab handoff ────────────────────────────────────────────
    // The modal is retired; Flash Cards lives in the PWA sidebar. Inside the
    // PWA this event exits the immersive overlay and switches the active
    // section; in the standalone sandbox (no PWA shell) it is a no-op.
    function goToFlashCardsTab() {
      try {
        window.dispatchEvent(
          new CustomEvent("incise-navigate", { detail: { section: "flashcards" } })
        );
      } catch (_) {}
    }

    // Right-click on a graph node opens a small frosted context bar. Clicks
    // anywhere else dismiss it. The menu reuses the existing handlers so the
    // backend writes are identical to the StudyOverlay path.
    function handleNodeRightClick(node, event) {
      if (!node || !node.id) return;
      try { event && event.preventDefault && event.preventDefault(); } catch (_) {}
      const x = event?.clientX ?? window.innerWidth / 2;
      const y = event?.clientY ?? window.innerHeight / 2;
      const titleNode = (structure?.nodes || []).find((n) => n.id === node.id);
      setContextMenu({
        nodeId: node.id,
        title: titleNode?.title || node.id,
        x, y,
      });
    }
    function dismissContextMenu() { setContextMenu(null); }

    // Right-click on a personal edge opens a small "delete this connection" menu.
    // Vault-canonical edges are not interactive — we only handle link.personal.
    function handleLinkRightClick(link, event) {
      if (!link || !link.personal || !link.edge_id) return;
      try { event && event.preventDefault && event.preventDefault(); } catch (_) {}
      const x = event?.clientX ?? window.innerWidth / 2;
      const y = event?.clientY ?? window.innerHeight / 2;
      const sId = typeof link.source === "object" ? link.source.id : link.source;
      const tId = typeof link.target === "object" ? link.target.id : link.target;
      const sNode = (structure?.nodes || []).find((n) => n.id === sId);
      const tNode = (structure?.nodes || []).find((n) => n.id === tId);
      const sTitle = sNode?.title || sId;
      const tTitle = tNode?.title || tId;
      setLinkContextMenu({
        edgeId: link.edge_id,
        title: `${sTitle} ↔ ${tTitle}`,
        x, y,
      });
    }
    function dismissLinkContextMenu() { setLinkContextMenu(null); }
    async function deletePersonalEdgeFromMenu(edgeId) {
      dismissLinkContextMenu();
      await deletePersonalEdge(edgeId);
    }

    // Open the study panel with the note editor expanded for a given node.
    async function openNodeWithNote(nodeId) {
      dismissContextMenu();
      // First open the panel (handleNodeClick honors linking-mode and exhausted nodes)
      await handleNodeClick({ id: nodeId });
      // The ConstructionPanel auto-expands its note editor when an annotation
      // already exists; for a brand-new note we set a small flag so it opens.
      // Simpler v1: just toast a hint if no existing note.
      if (!construction.annotations[nodeId]) {
        setToast({ text: "Click '+ Add a note' in the panel to start writing." });
      }
    }

    // Toggle attention directly from the context menu (no panel open required).
    async function toggleAttentionFromMenu(nodeId) {
      dismissContextMenu();
      await toggleAttention(nodeId);
    }

    // Start linking from the context menu (no panel open required).
    function startLinkingFromMenu(nodeId) {
      dismissContextMenu();
      startLinking(nodeId);
    }

    async function deletePersonalEdge(edgeId) {
      try {
        await api(`/personal-edge/${edgeId}`, { method: "DELETE" });
        setConstruction((prev) => ({
          ...prev,
          personal_edges: prev.personal_edges.filter((e) => e.id !== edgeId),
        }));
        setToast({ text: "Personal edge removed." });
      } catch (e) { setError(e.message); }
    }

    async function nextQuestion() {
      try {
        let next;
        if (prefetchRef.current) {
          next = await prefetchRef.current;
          prefetchRef.current = null;
          if (next && next.__prefetchError) throw next.__prefetchError;
        } else {
          const params = new URLSearchParams({ node_id: studyingNodeId });
          next = await api(`/session/${session.session_id}/next?${params}`, { method: "POST" });
        }
        setInteraction(next);
        setAnswerResult(null);
      } catch (e) {
        if (e.status === 404) {
          setToast({ text: `No more questions on this node — try another.`, warn: true });
          closeStudy();
        } else {
          setError(e.message);
        }
      }
    }

    async function submitAnswer({ choiceIdx, confidence, timeSpentSeconds }) {
      try {
        const r = await api(`/session/${session.session_id}/answer`, {
          method: "POST",
          body: JSON.stringify({
            question_hash: interaction.question.content_hash,
            selected_choice: choiceIdx,
            confidence_pre: confidence,
            time_spent_seconds: timeSpentSeconds,
          }),
        });
        setAnswerResult(r);
        // Phase 1 (Alec's bug): remember which choice was picked so a review
        // restore can re-render the red "your wrong pick" state — InteractionView
        // resets its internal selectedIdx whenever `interaction` changes.
        if (routeStudyOpen) {
          setRouteQuestionCache(prev => ({
            ...prev,
            [currentPosition]: { ...prev[currentPosition], selectedIdx: choiceIdx },
          }));
        }
        // Mastery/eligibility for this node just shifted → drop its cached /info so the
        // next open (or hover-prefetch) refetches fresh tier + eligible-question count.
        if (studyingNodeId) nodeInfoCacheRef.current.delete(studyingNodeId);
        // v103: the backend now applies the mastery nudge SYNCHRONOUSLY on
        // /answer, so refresh the Live Mastery rail immediately instead of
        // waiting on the analyzer poll (which can take seconds or be skipped
        // under cost caps).
        if (studyingNodeId) {
          prefetchNodeInfo(studyingNodeId).then(info => {
            setRailMastery(m => ({ ...m, [studyingNodeId]: {
              mastery_estimate: info.mastery_estimate,
              attempts: info.attempts,
            } }));
          }).catch(() => {});
        }
      } catch (e) { setError(e.message); }
    }

    // ── Day 5: teach-back handlers ─────────────────────────────────────────
    function openTeachBack() {
      if (!answerResult || !answerResult.teach_back_required) return;
      setTeachBackContext({
        nodeId: answerResult.teach_back_node_id,
        nodeTitle: answerResult.teach_back_node_title,
        questionHash: interaction?.question?.content_hash,
        prompt: answerResult.teach_back_prompt,
      });
      setTeachBackOpen(answerResult.teach_back_node_id || "pending");
      setTeachBackResult(null);
      setTeachBackError(null);
    }

    async function submitTeachBack(responseText) {
      if (!session || !teachBackContext) return;
      setTeachBackBusy(true);
      setTeachBackError(null);
      try {
        const r = await api(`/session/${session.session_id}/teachback`, {
          method: "POST",
          timeoutMs: 120000,  // Sonnet rubric judge — well beyond the 30s default
          body: JSON.stringify({
            question_hash: teachBackContext.questionHash,
            response_text: responseText,
            node_id: teachBackContext.nodeId || null,
          }),
        });
        setTeachBackResult(r);
        // Mastery for this node just shifted → drop its cached /info (eligible/tier).
        if (teachBackContext?.nodeId) nodeInfoCacheRef.current.delete(teachBackContext.nodeId);
        // Refresh graph state so any per-concept mastery nudges show up
        try {
          const [st, route] = await Promise.all([
            api("/graph/state"),
            api("/route/current?k=" + sessionLen),
          ]);
          setGraphState(st);
          setRouteMetrics(route);
        } catch (_) {}
      } catch (e) {
        // 403 expertise-reversal is a structured detail — surface clearly.
        // Error message format from api(): "{status}: {body_text}"
        const raw = e.message || "Teach-back failed.";
        const sep = raw.indexOf(": ");
        const body = sep > 0 ? raw.slice(sep + 2) : raw;
        let detail = null;
        try {
          const parsed = JSON.parse(body);
          detail = parsed.detail !== undefined ? parsed.detail : parsed;
        } catch (_) {
          detail = body;
        }
        if (detail && typeof detail === "object" && detail.error === "expertise_reversal_gate") {
          setTeachBackError({
            kind: "expertise_reversal",
            currentMastery: detail.current_mastery,
            floor: detail.mastery_floor,
            message: detail.message,
          });
        } else {
          const msg = typeof detail === "string"
            ? detail
            : (detail && detail.message) || JSON.stringify(detail);
          setTeachBackError({ kind: "generic", message: msg });
        }
      } finally {
        setTeachBackBusy(false);
      }
    }

    function closeTeachBack() {
      setTeachBackOpen(null);
      setTeachBackContext(null);
      setTeachBackResult(null);
      setTeachBackError(null);
      setTeachBackBusy(false);
    }

    async function closeStudy() {
      // Refresh graph state + route metrics so the node's color updates
      prefetchRef.current = null;
      setStudyingNodeId(null);
      setStudyingNodeInfo(null);
      setInteraction(null);
      setAnswerResult(null);
      try {
        const [st, route] = await Promise.all([
          api("/graph/state"),
          api("/route/current?k=" + sessionLen),
        ]);
        setGraphState(st);
        setRouteMetrics(route);
      } catch (_) {}
      // Stage 3d: per-session recap. If the learner promoted any concepts
      // during this study sitting, surface the recap card before they
      // return to the graph view. Empty list → no recap (don't nag with a
      // "0 promotions" card).
      if (sessionTierCrossings.length > 0) {
        setRecapCardOpen(true);
      }
    }
    // Stage 3d: dismiss handler — also clears the accumulator so the next
    // study sitting starts fresh.
    function closeRecap() {
      setRecapCardOpen(false);
      setSessionTierCrossings([]);
    }

    // ── Day 4 Slice C: cold-start + diagnostic exam handlers ────────────────

    function dismissColdStart() {
      sessionStorage.setItem("brainTrace.coldStartDismissed", "1");
      setColdStartVisible(false);
      // Now that cold-start is out of the way, show the tutorial if not seen yet
      // this session AND not permanently dismissed via "Don't show this again".
      if (sessionStorage.getItem("brainTrace.tutorialSeenAt") === null
          && localStorage.getItem("brainTrace.tourDismissedForever") !== "1") {
        setTutorialStep(1);
      }
    }

    // Slice D-polish: tutorial step navigation (now 6 steps — added intro card
    // grounding the experience in the learning science)
    function advanceTutorial() {
      if (tutorialStep >= 6) {
        sessionStorage.setItem("brainTrace.tutorialSeenAt", String(Date.now()));
        if (tourDontShowAgain) localStorage.setItem("brainTrace.tourDismissedForever", "1");
        setTutorialStep(0);
      } else {
        setTutorialStep(tutorialStep + 1);
      }
    }
    function skipTutorial() {
      sessionStorage.setItem("brainTrace.tutorialSeenAt", String(Date.now()));
      if (tourDontShowAgain) localStorage.setItem("brainTrace.tourDismissedForever", "1");
      setTutorialStep(0);
    }
    // Slice D-polish v3: re-trigger the tour from the bottom-left rail. Clears
    // both the per-session seen flag AND the persistent "don't show again" flag
    // (the user explicitly asked to see it), and opens at Card 1.
    function replayTutorial() {
      sessionStorage.removeItem("brainTrace.tutorialSeenAt");
      localStorage.removeItem("brainTrace.tourDismissedForever");
      setTourDontShowAgain(false);
      setTutorialStep(1);
    }

    // Reset Brain Trace — wipes all per-user state on the server, then clears
    // session-scoped frontend flags (cinematic-open, tutorial-seen, etc.) and
    // hard-reloads so the user lands on a true cold-start. The 1-second receipt
    // pause is intentional — destructive actions deserve a beat of acknowledgment
    // ("yes, this just happened") rather than a silent reload.
    //
    // Cohesive-ecosystem note: when this ports into incise_app, this same handler
    // (or its server equivalent) will additionally clear question_progress rows
    // contributed by Test/Tutor mode. Sandbox already wipes question_progress
    // structurally, so the bidirectional reset semantics are correct today —
    // they just don't have a second app to touch yet.
    async function performReset() {
      if (resetBusy) return;
      setResetBusy(true);
      try {
        const report = await api("/learner/reset", { method: "POST" });
        setResetReport(report);
        // Clear session-scoped UI flags so first reload after reset feels truly fresh
        try {
          sessionStorage.removeItem("brainTrace.cinematicOpenFiredAt");
          sessionStorage.removeItem("brainTrace.tutorialSeenAt");
          sessionStorage.removeItem("brainTrace.lastCameraView");
        } catch (_) { /* sessionStorage can throw in private mode; best-effort */ }
        // Brief receipt then reload — full reload guarantees no stale React state
        setTimeout(() => { window.location.reload(); }, 1100);
      } catch (e) {
        setError(`Reset failed: ${e.message}`);
        setResetBusy(false);
      }
    }

    async function startDiagnostic() {
      setColdStartVisible(false);
      try {
        if (!pools.length) throw new Error("No question pool loaded");
        const s = await api("/diagnostic_exam/start", {
          method: "POST",
          body: JSON.stringify({ pool_path: pools[0].path, target_count: 30 }),
        });
        setDiagnosticSession(s);
        setDiagnosticAnswerResult(null);
        const q = await api(`/diagnostic_exam/${s.session_id}/next`, { method: "POST" });
        setDiagnosticQuestion(q);
      } catch (e) { setError(e.message); }
    }

    async function submitDiagnosticAnswer({ choiceIdx, confidence, timeSpentSeconds }) {
      try {
        const r = await api(`/session/${diagnosticSession.session_id}/answer`, {
          method: "POST",
          body: JSON.stringify({
            question_hash: diagnosticQuestion.question.content_hash,
            selected_choice: choiceIdx,
            confidence_pre: confidence,
            time_spent_seconds: timeSpentSeconds,
          }),
        });
        setDiagnosticAnswerResult(r);
      } catch (e) { setError(e.message); }
    }

    async function nextDiagnosticQuestion() {
      try {
        const q = await api(`/diagnostic_exam/${diagnosticSession.session_id}/next`, { method: "POST" });
        setDiagnosticQuestion(q);
        setDiagnosticAnswerResult(null);
      } catch (e) {
        // Baseline exhausted → backend replies 404 {error:"diagnostic_complete"}.
        // Branch on err.status; the message format is host-specific (sandbox api
        // vs PWA brainTraceFetch) and must never be sniffed for status codes.
        if (e.status === 404) {
          // Diagnostic complete — fetch results
          try {
            const results = await api(
              `/diagnostic_exam/${diagnosticSession.session_id}/complete`,
              { method: "POST" },
            );
            setDiagnosticResults(results);
            setDiagnosticQuestion(null);
            setDiagnosticAnswerResult(null);
            // The diagnostic seeds mastery + may consume questions across many nodes →
            // clear the /info cache so reopened nodes show fresh tier + eligible count.
            nodeInfoCacheRef.current.clear();
          } catch (e2) { setError(e2.message); }
        } else {
          setError(e.message);
        }
      }
    }

    async function beginAdaptiveStudyFromDiagnostic() {
      // Pull the recommended next node from the diagnostic results' next_node payload
      // so we can open the study panel for it directly. Fixes the dead-end where the
      // user finished the diagnostic and didn't know how to start studying.
      const nextNodeId = diagnosticResults?.next_node?.node_id;
      setDiagnosticResults(null);
      setDiagnosticSession(null);
      setDiagnosticQuestion(null);
      setDiagnosticAnswerResult(null);
      try {
        const [st, route] = await Promise.all([
          api("/graph/state"),
          api("/route/current?k=" + sessionLen),
        ]);
        setGraphState(st);
        setRouteMetrics(route);
        await refreshLearnerCounters();
      } catch (_) {}
      // Open the study panel for position-1 (or the diagnostic's next_node if route changed)
      const target = nextNodeId || routeMetrics?.active_route?.[0]?.node_id;
      if (target) {
        await handleNodeClick({ id: target });
      }
    }

    // Permanent footer CTA: "Study Next on Route →" — opens position-1 study panel.
    // Same as the post-diagnostic flow, but available from the graph view at any time.
    // Open one route position: reuse the node-open plumbing (session + /next + interaction
    // state). The render gate shows RouteStudySurface (not StudyOverlay) while
    // routeStudyOpen is true. positionsOverride avoids the async-setState race when
    // studyNextOnRoute opens position 0 in the same tick it sets routePositions.
    async function openRoutePosition(idx, positionsOverride) {
      const positions = positionsOverride || routePositions;
      const pos = positions[idx];
      if (!pos) return;
      setError(null);  // a stale draw/submit error must not outlive navigation
      // Phase 1 (Alec's bug): an already-visited position restores its cached
      // question + answer state (read-only once answered) instead of drawing a
      // new question. Backend /next excludes session-answered hashes, so a
      // re-draw here could only serve a different question — or node_exhausted,
      // which froze the surface on its loading skeleton.
      const cached = routeQuestionCache[idx];
      if (cached && cached.interaction) {
        clickTokenRef.current++;          // invalidate any in-flight draw
        reviewRestoreRef.current = true;  // suppress the prefetch effects this commit
        setCurrentPosition(idx);
        setStudyingNodeId(pos.node_id);   // late analyzer patches target the right rail row
        setInteraction(cached.interaction);
        setAnswerResult(cached.answerResult || null);
        return;
      }
      setCurrentPosition(idx);
      const gs = graphState?.per_node?.[pos.node_id]
        || cachedGraphState?.per_node?.[pos.node_id] || {};
      await handleNodeClick({
        id: pos.node_id,
        name: pos.title,
        section: pos.section,
        mastery_estimate: gs.mastery_estimate ?? 0,
        attempts: gs.attempts ?? 0,
      });
    }

    async function studyNextOnRoute() {
      // Build the route walk from the full active_route. It arrives ONLY via the slow
      // /route/current?k=10 fetch, and the route-metrics cache is scalars-only (it keeps
      // the "ON ROUTE N" count but drops the array — a v89 rule protecting cinematic/decay
      // targeting). So on a fast open (before the mount fetch lands) routeMetrics.active_route
      // can be null even though the header already shows a count. Previously we fell back to
      // the single cached first step and opened the surface with N=1 — the "rail/mini-map
      // shows only one topic" bug. Fix: if active_route isn't in memory yet, AWAIT a fresh
      // fetch before opening; only if that also yields nothing do we use the cached step.
      let route = routeMetrics?.active_route;
      // Honor the chosen session length. Refetch at k=sessionLen only when we don't yet have
      // enough positions cached (covers both the fast-open race — active_route still null —
      // and longer sessions than the default k=10 mount fetch). For shorter sessions we just
      // slice the cached route below. This changes ONLY how many positions we walk; the
      // working-set N and the mastery/spacing algorithm are untouched.
      if (!route || route.length < sessionLen) {
        // v105: open the surface OPTIMISTICALLY before awaiting the route
        // fetch — the user reported a long dead pause between clicking the
        // CTA and anything appearing, because the surface (and its question
        // skeleton) was gated behind this await. A provisional rail paints
        // from whatever route slice is cached; the real positions land when
        // the fetch resolves (setRoutePositions below re-renders the rail —
        // the v89 "rail shows one topic" bug stays fixed because we still
        // await the fresh route before opening POSITION 0).
        const provisional = (route && route.length)
          ? route.map(r => ({ position: r.position, node_id: r.node_id, title: r.title, section: r.psite_section }))
          : (cachedRouteStep && cachedRouteStep.node_id
              ? [{ position: 1, node_id: cachedRouteStep.node_id,
                   title: cachedRouteStep.title || cachedRouteStep.concept_title || "Next topic",
                   section: cachedRouteStep.psite_section }]
              : []);
        setRoutePositions(provisional.slice(0, sessionLen));
        setRouteQuestionCache({});  // new walk — indices remap; stale cache must not restore
        setCurrentPosition(0);
        setInteraction(null);
        setAnswerResult(null);
        setRouteStudyOpen(true);
        try {
          const rm = await api("/route/current?k=" + sessionLen);
          setRouteMetrics(rm);
          _btSaveRouteMetrics(rm);
          _btSaveRoutePaint(rm && rm.active_route);
          _btSaveRouteStep(rm && rm.active_route && rm.active_route[0]);
          route = rm && rm.active_route;
        } catch (_) { /* fall through to the cached single step below */ }
      }
      let positions = (route && route.length)
        ? route.map(r => ({ position: r.position, node_id: r.node_id, title: r.title, section: r.psite_section }))
        : (cachedRouteStep && cachedRouteStep.node_id
            ? [{ position: 1, node_id: cachedRouteStep.node_id,
                 title: cachedRouteStep.title || cachedRouteStep.concept_title || "Next topic",
                 section: cachedRouteStep.psite_section }]
            : []);
      positions = positions.slice(0, sessionLen);   // walk only the chosen number of positions
      if (!positions.length) {
        setRouteStudyOpen(false);  // undo the optimistic open
        setToast({ text: "No route available — take the diagnostic exam first.", warn: true });
        return;
      }
      setRouteQuestionCache({});  // covers the fast path that skips the optimistic block above
      setRoutePositions(positions);
      // Seed the rail bars from last-known per-node mastery so they paint at once;
      // each answer live-patches the answered node via a single /info fetch.
      const seed = {};
      for (const p of positions) {
        const gs = graphState?.per_node?.[p.node_id]
          || cachedGraphState?.per_node?.[p.node_id] || {};
        seed[p.node_id] = { mastery_estimate: gs.mastery_estimate ?? 0, attempts: gs.attempts ?? 0 };
      }
      setRailMastery(seed);
      setRouteStudyOpen(true);
      await openRoutePosition(0, positions);
    }

    // "Next topic →" advances to the next route position (not the next question within a
    // node). One question per position — the canonical route→question mapping.
    async function nextRoutePosition() {
      const nextIdx = currentPosition + 1;
      if (nextIdx >= routePositions.length) {
        await endRouteStudy();
        return;
      }
      await openRoutePosition(nextIdx);
    }

    async function endRouteStudy() {
      routeNextPrefetchRef.current = null;  // v105: drop any warmed next-position payload
      setRouteQuestionCache({});            // Phase 1: the review cache is per-walk
      setRouteStudyOpen(false);
      await closeStudy();  // refresh /graph/state + /route/current + recap; clears studyingNodeId
    }

    return (
      <div className="app-shell">
        {/* Full-bleed graph */}
        {structure && (
          <GraphView
            structure={structure}
            graphState={graphState}
            routeMetrics={routeMetrics}
            cachedGraphState={cachedGraphState}
            cachedRoutePaint={cachedRoutePaint}
            graphStateSettled={graphStateSettled}
            routeSettled={routeSettled}
            activeSection={activeSection}
            tutorialStep={tutorialStep}
            clusterCycleSection={clusterCycleSection}
            construction={construction}
            linkingFromNodeId={linkingFromNodeId}
            decayedNodeIds={routeMetrics?.decayed_node_ids || []}
            tierCrossingsRef={tierCrossingsRef}
            pulseTick={pulseTick}
            onNodeClick={handleNodeClick}
            onNodePrefetch={prefetchNodeInfo}
            onNodeRightClick={handleNodeRightClick}
            onLinkRightClick={handleLinkRightClick}
            disabled={!!studyingNodeId}
          />
        )}

        {/* First-open skeleton — fills the center while /graph/structure is in
            flight (repeat opens hit the structure cache and skip this). */}
        {!structure && <BrainTraceLoadingSkeleton />}

        {/* Top bar — single grid holding ALL top chrome as siblings
            (brand | section filter | legend, with title + metrics in lower
            rows). Grid tracks make bar/legend/metrics overlap impossible by
            construction — see .top-bar CSS. */}
        <div className="top-bar">
          {/* v110: title + metrics + filter live in a nested content grid
              (.top-bar-content) so the tall mastery legend — a sibling in the
              OUTER grid — no longer inflates their row heights. Inner grid:
              row 1 = [title | filter] (title top-left, level with the search
              bar; filter keeps the full remaining width), row 2 = metrics
              tucked immediately beneath. Before: h1 align-self:center floated
              the title down and the legend's 2-row span shoved metrics far
              below with a big empty gap. */}
          <div className="top-bar-content">
          <h1>Brain Trace</h1>
          {(routeMetrics || cachedStats || structure) && (() => {
            const stats = routeMetrics || cachedStats;  // fresh fetch wins; cache fills the tiles until it lands
            return (
            <div className="header-metrics">
              <div className="header-metric">
                {/* Fix D — visible label is "Nodes mastered" (the substrate that
                    crosses the user's mental-model threshold). The API field
                    name stays `concepts_mastered_count` for downstream
                    Supabase migration compatibility — the rename is UI-only. */}
                <div className="label">Nodes mastered</div>
                <div className="value">{stats?.concepts_mastered_count ?? "—"}</div>
              </div>
              <div className="header-metric">
                <div className="label">On route</div>
                {/* v106 honesty: when question supply caps the route below the
                    requested length, say so instead of silently showing less. */}
                <div className="value" title={
                  stats?.positions_available != null
                  && stats?.positions_requested != null
                  && stats.positions_available < stats.positions_requested
                    ? "Fewer positions than requested — more unlock as questions refresh (30-day no-repeat) and coverage grows"
                    : undefined
                }>
                  {stats?.positions_available != null
                   && stats?.positions_requested != null
                   && stats.positions_available < stats.positions_requested
                    ? `${stats.positions_available} of ${stats.positions_requested}`
                    : (stats?.concepts_in_focus_count ?? "—")}
                </div>
              </div>
              <div className="header-metric">
                <div className="label">Total nodes</div>
                {/* paints instantly from structure even before the slow planner returns */}
                <div className="value">{stats?.total_traces_count ?? structure?.nodes?.length ?? "—"}</div>
              </div>
              <div className="header-metric">
                <div className="label">% mastery</div>
                <div className="value">
                  {stats?.pct_mastery ?? "—"}
                  <span style={{ fontSize: 13, color: "var(--muted)", marginLeft: 2 }}>%</span>
                </div>
              </div>
              <div className="header-metric">
                <div className="label">Mastery in</div>
                <div className="value">
                  {stats?.estimated_sessions_to_mastery
                    ? `${stats.estimated_sessions_to_mastery.lower}–${stats.estimated_sessions_to_mastery.upper}`
                    : "—"}
                  <span style={{ fontSize: 13, color: "var(--muted)", marginLeft: 4 }}>sessions</span>
                </div>
              </div>
            </div>
            );
          })()}

          {/* Section filter pills — inner-grid filter track (grid-area: filter) */}
          {structure && (
            <SectionFilter
              structure={structure}
              activeSection={activeSection}
              highlightedSection={clusterCycleSection}
              onChange={setActiveSection}
              onResultClick={({ node_id }) => {
                if (node_id) handleNodeClick({ id: node_id });
              }}
            />
          )}
          </div>{/* /top-bar-content */}

          {/* Mastery legend — hover surfaces the full tier table from the tutorial,
              so the legend acts as a quick-reference glossary for what each color
              on the graph actually means in mastery terms. The wrapper is the
              grid's legend area (position:relative retained so the hoisted
              tooltip anchor keeps working — see .legend CSS comment). */}
          {/* Mobile: the always-open legend is the single biggest source of
              top-chrome clutter on a phone — it collapses behind a "Legend"
              chip in the footer bar (bottom sheet on tap). Desktop unchanged. */}
          {!isMobileView && (
          <HoverTooltip
            wide
            sideLeft
            eyebrow="Mastery tiers"
            title="What each color means"
            richBody={<MasteryTierTable />}
            wrapperStyle={{ gridArea: "legend", justifySelf: "end", position: "relative", zIndex: 5 }}
          >
            <Legend />
          </HoverTooltip>
          )}
        </div>

        {/* Day 5: catch-up "welcome back" banner. Fires when the planner
            reports catchup_active = true (last_session > 7 days AND there
            are decayed nodes). Dismissable; stays dismissed for the session. */}
        {routeMetrics?.catchup_active && !catchupDismissed && (
          <CatchupBanner
            daysAway={Math.round(routeMetrics.days_since_last_session || 0)}
            decayedCount={routeMetrics.decayed_count || 0}
            onDismiss={() => setCatchupDismissed(true)}
          />
        )}

        {/* Footer action bar — three clusters (left = diagnostic/reset, center = primary
            Start CTA, right = flashcards/rapid-review) as the three tracks of a
            minmax(0,1fr) auto minmax(0,1fr) grid: the CTA is centered by the equal
            flanks and reserves real width in flow, so sibling growth (session pill,
            flashcards label) wraps within its own column and can neither displace
            nor overlap it. Reflows with the sidebar inset (absolute within .app-shell). */}
        {!isMobileView && (
        <div className="footer-bar">
        {/* Footer-left — diagnostic / recalibration entry point (always available,
            even after dismissing the cold-start CTA). Label depends on history.
            Reset Brain Trace sits inline with the other CTAs but with red ghost
            styling so the destructive action reads visually distinct. */}
        <div className="footer-overlay-left">
          <HoverTooltip
            leftAnchor
            eyebrow={diagnosticExamsCompleted > 0 ? "Recalibration" : "Cold-start onboarding"}
            title={diagnosticExamsCompleted > 0 ? "Recalibrate your map" : "Seed your knowledge map"}
            body={diagnosticExamsCompleted > 0
              ? "Re-run the stratified diagnostic to refresh your baseline — useful after studying outside the platform, or when the route feels stale relative to where you actually stand."
              : "Completing the diagnostic exam will seed your map based on your individualized strengths and weaknesses, and give Brain Trace a starting point to guide your most efficient learning route."}
          >
            <button
              className="btn-secondary"
              onClick={startDiagnostic}
            >
              {diagnosticExamsCompleted > 0 ? "Recalibration exam →" : "Take diagnostic exam →"}
            </button>
          </HoverTooltip>
          <button
            className="btn-secondary"
            onClick={replayTutorial}
            title="Replay the introductory tour"
            style={{ padding: "8px 12px", fontSize: 12 }}
          >
            ↻ Replay tour
          </button>
          <button
            className="btn-danger-ghost"
            onClick={() => setResetModalOpen(true)}
            title="Permanently wipe all of your Brain Trace progress and start fresh"
          >
            <span className="dot" aria-hidden="true" />
            Reset Brain Trace
          </button>
        </div>

        {/* Footer-center — primary "Start your study adventure" CTA + alpha
            disclaimer. The CTA opens the study panel for the route's
            position-1 node (same target as the previous Study Next on Route). */}
        <div className="footer-overlay-center">
          <div className="alpha-note">Alpha product build.</div>
          {(() => {
            // Fix D — concept-substrate route step UI. The CTA tooltip now
            // names the target *concept* + its parent topic context, and
            // surfaces the planner's rationale (which already encodes the
            // confidence band when low/medium) as the body copy. The CTA
            // label still anchors on the parent topic to keep the click
            // affordance unchanged.
            // Render from the FRESH route when present, else the cached first step so the
            // CTA appears instantly on repeat opens (parity with the now-instant graph).
            // Fresh setRouteMetrics overrides; null on a brand-new user → no CTA (the
            // cold-start overlay handles them).
            const step1 = routeMetrics?.active_route?.[0] || cachedRouteStep;
            if (!step1) return null;
            const concept = step1.concept_title || step1.concept_tag;
            const parent = step1.title;
            const conf = step1.confidence_band;
            const confLabel = conf === "low"
              ? "Low confidence — limited data so far"
              : conf === "medium"
              ? "Medium confidence — more attempts will sharpen this"
              : null;
            return (
              <React.Fragment>
              <SessionLengthPicker value={sessionLen} onChange={updateSessionLen} />
              <HoverTooltip
                eyebrow={concept ? `Position 1 — ${concept}` : "Position 1 on your route"}
                title={parent}
                body={
                  step1.rationale ||
                  "This will guide you to studying your weakest and highest-yield topics first — the single concept Brain Trace's route optimizer believes will move your readiness fastest right now."
                }
                richBody={confLabel ? (
                  <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 8, fontFamily: "'DM Mono', monospace", textTransform: "uppercase", letterSpacing: "0.08em" }}>
                    {confLabel}
                  </div>
                ) : undefined}
              >
                <button
                  className="start-cta"
                  onClick={studyNextOnRoute}
                >
                  Start your study adventure →
                </button>
              </HoverTooltip>
              </React.Fragment>
            );
          })()}
        </div>

        {/* Footer-right — flashcards + rapid-review cluster (v109: restored to
            the RIGHT track). v107 crammed these into footer-overlay-left, which
            overflowed a single minmax(0,1fr) track and wrapped the whole action
            bar onto a second row. Splitting 3 left / 2 right keeps every button
            on one horizontal line while the CTA stays centered (v104 grid
            contract). rightAnchor so these edge tooltips expand leftward and
            stay on-screen. Session # pill stays removed (clutter). */}
        <div className="footer-overlay">
          <HoverTooltip
            rightAnchor
            eyebrow="Up to 30 / week"
            title="Targeted, ruthlessly culled flashcards"
            body="Cards are generated weekly from your weakest nodes and recently missed questions, filed into decks by exam domain, and scheduled with spaced repetition. Reviews flow back into your Brain Trace map. Opens the Flash Cards tab."
          >
            <button
              className="btn-secondary rapid-review-cta"
              onClick={goToFlashCardsTab}
            >
              ✦ Flash Cards
            </button>
          </HoverTooltip>
          <HoverTooltip
            rightAnchor
            eyebrow="Once per week"
            title="Cornell-style cram packet"
            body="Generate a Cornell-style cram packet from your weakest topics and the personal notes you've written into your Brain Trace — once per week. Synthesized to your highest-leverage learning needs, not a generic review."
          >
            <button
              className="btn-secondary rapid-review-cta"
              onClick={openRapidReview}
            >
              ✦ Generate Rapid Review Notes
            </button>
          </HoverTooltip>
        </div>
        </div>
        )}{/* /footer-bar (desktop) */}

        {/* ── Mobile footer — stacked bottom bar (≤768px only) ──────────────
            Row 1: horizontally scrollable chip row holding every secondary
            action (legend toggle, diagnostic, flash cards, rapid review, tour,
            reset) so nothing overlaps the primary CTA or the graph.
            Row 2: alpha note + session-length pills + full-width Start CTA,
            padded for the iOS home-indicator safe area. HoverTooltips are
            omitted — hover has no meaning on touch and the wrappers were a
            large part of the phone-viewport clutter. */}
        {isMobileView && (
        <div className="footer-bar footer-bar-mobile">
          {/* Route card rail — the phone-legible representation of the route.
              On a 375px viewport the graph's numbered beads are reference
              points, not primary affordances; these swipeable cards carry the
              actual content (position, topic, mastery tier) and tapping one
              opens that node's study panel (same path as tapping its bead). */}
          {(() => {
            const route = routeMetrics?.active_route || [];
            if (!route.length) return null;
            return (
              <div className="route-rail-mobile">
                {route.map((r) => {
                  const gs = graphState?.per_node?.[r.node_id]
                    || cachedGraphState?.per_node?.[r.node_id] || {};
                  const tier = getTier(gs.mastery_estimate ?? 0, gs.attempts ?? 0);
                  return (
                    <button
                      key={`${r.position}-${r.node_id}`}
                      className="route-card"
                      onClick={() => r.node_id && handleNodeClick({ id: r.node_id })}
                    >
                      <span className="route-card-num">{r.position}</span>
                      <span className="route-card-body">
                        <span className="route-card-title">{r.concept_title || r.title}</span>
                        <span className="route-card-meta">
                          <span className="route-card-dot" style={{ background: TIER_COLOR[tier] }} />
                          {TIER_LABEL[tier]}
                        </span>
                      </span>
                    </button>
                  );
                })}
              </div>
            );
          })()}
          <div className="footer-chips-row">
            <button className="btn-secondary footer-chip" onClick={() => setLegendOpen(o => !o)} aria-expanded={legendOpen}>
              ◉ Legend
            </button>
            <button className="btn-secondary footer-chip" onClick={startDiagnostic}>
              {diagnosticExamsCompleted > 0 ? "Recalibration exam" : "Diagnostic exam"}
            </button>
            <button className="btn-secondary footer-chip" onClick={goToFlashCardsTab}>
              ✦ Flash Cards
            </button>
            <button className="btn-secondary footer-chip" onClick={openRapidReview}>
              ✦ Rapid Review
            </button>
            <button className="btn-secondary footer-chip" onClick={replayTutorial}>
              ↻ Tour
            </button>
            <button className="btn-danger-ghost footer-chip" onClick={() => setResetModalOpen(true)}>
              <span className="dot" aria-hidden="true" />
              Reset
            </button>
          </div>
          {(() => {
            const step1 = routeMetrics?.active_route?.[0] || cachedRouteStep;
            if (!step1) return null;
            return (
              <div className="footer-primary-row">
                <div className="footer-primary-meta">
                  <SessionLengthPicker value={sessionLen} onChange={updateSessionLen} />
                  <div className="alpha-note">Alpha build</div>
                </div>
                <button className="start-cta" onClick={studyNextOnRoute}>
                  Start your study adventure →
                </button>
              </div>
            );
          })()}
        </div>
        )}

        {/* Mobile legend bottom sheet — tap scrim or Close to dismiss. */}
        {isMobileView && legendOpen && (
          <div className="legend-sheet-scrim" onClick={() => setLegendOpen(false)}>
            <div className="legend-sheet" onClick={(e) => e.stopPropagation()}>
              <Legend />
              <button className="btn-secondary legend-sheet-close" onClick={() => setLegendOpen(false)}>
                Close
              </button>
            </div>
          </div>
        )}

        {/* Study overlay (frosted glass slide-in) — node-click path. Suppressed during a
            route walk (routeStudyOpen), which renders RouteStudySurface instead. */}
        {studyingNodeId && !routeStudyOpen && (
          <StudyOverlay
            nodeId={studyingNodeId}
            nodeInfo={studyingNodeInfo}
            interaction={interaction}
            answerResult={answerResult}
            onSubmit={submitAnswer}
            onNext={nextQuestion}
            onClose={closeStudy}
            walkthroughText={walkthroughText}
            walkthroughLoading={walkthroughLoading}
            walkthroughError={walkthroughError}
            onWalkthrough={fetchWalkthrough}
            onStudyRelated={studyRelatedConcept}
            onMarkNeedsWork={markAsNeedsWork}
            annotation={construction.annotations[studyingNodeId]?.content_text || ""}
            isAttention={!!construction.attention[studyingNodeId]}
            onSaveAnnotation={(text) => saveAnnotation(studyingNodeId, text)}
            onToggleAttention={() => toggleAttention(studyingNodeId)}
            onStartLinking={() => { closeStudy(); startLinking(studyingNodeId); }}
            onTeachBack={openTeachBack}
            onOpenFlashcards={goToFlashCardsTab}
          />
        )}

        {/* Route study surface (Slice 1) — full-screen tutor-styled walk of the active
            route. Reuses interaction/answerResult/session state; onNext advances the
            route position (not the within-node question). */}
        {routeStudyOpen && (
          <RouteStudySurface
            routePositions={routePositions}
            currentPosition={currentPosition}
            session={session}
            interaction={interaction}
            answerResult={answerResult}
            nodeInfo={studyingNodeInfo}
            structure={structure}
            railMastery={railMastery}
            pulseNodeId={pulseNodeId}
            railFlashNodeId={railFlashNodeId}
            sessionTierCrossings={sessionTierCrossings}
            questionCache={routeQuestionCache}
            error={error}
            onRetry={() => openRoutePosition(currentPosition)}
            onDismissError={() => setError(null)}
            onSubmit={submitAnswer}
            onNext={nextRoutePosition}
            onSelectPosition={openRoutePosition}
            onEnd={endRouteStudy}
          />
        )}

        {/* Day 5: teach-back modal (post-MCQ rubric judge) */}
        {teachBackOpen && (
          <TeachBackModal
            context={teachBackContext}
            result={teachBackResult}
            busy={teachBackBusy}
            error={teachBackError}
            onSubmit={submitTeachBack}
            onClose={closeTeachBack}
          />
        )}

        {/* Linking-mode banner (top-center, frosted, dismissable) */}
        {linkingFromNodeId && (
          <div className="linking-banner">
            <span>Click a node to link it to <em>{linkingFromNodeId}</em></span>
            <button className="btn-ghost" onClick={cancelLinking}>Cancel</button>
          </div>
        )}

        {/* Cold-start CTA (shown when no answers ever AND not dismissed this session) */}
        {coldStartVisible && !diagnosticSession && !diagnosticResults && (
          <ColdStartOverlay
            onTakeDiagnostic={startDiagnostic}
            onDismiss={dismissColdStart}
          />
        )}

        {/* Diagnostic exam — full-screen overlay during in-flight exam */}
        {diagnosticSession && !diagnosticResults && diagnosticQuestion && (
          <DiagnosticExamOverlay
            session={diagnosticSession}
            question={diagnosticQuestion}
            answerResult={diagnosticAnswerResult}
            onSubmit={submitDiagnosticAnswer}
            onNext={nextDiagnosticQuestion}
          />
        )}

        {/* Diagnostic results — shown after /complete */}
        {diagnosticResults && (
          <DiagnosticResultsOverlay
            results={diagnosticResults}
            onDone={beginAdaptiveStudyFromDiagnostic}
          />
        )}

        {/* Onboarding tutorial — first-load only, sequenced through 4 hint bubbles */}
        {tutorialStep > 0 && (
          <TutorialOverlay
            step={tutorialStep}
            onAdvance={advanceTutorial}
            onSkip={skipTutorial}
            dontShowAgain={tourDontShowAgain}
            onToggleDontShowAgain={setTourDontShowAgain}
          />
        )}

        {/* Stage 3d: per-session tier-promotion recap. Fires when the user
            closes the study panel after promoting at least one concept's
            tier in this sitting. Lists each promotion grouped by new tier;
            spaced fast-path graduations get a ✦ marker. */}
        {recapCardOpen && (
          <SessionRecapCard
            crossings={sessionTierCrossings}
            onClose={closeRecap}
          />
        )}

        {/* Rapid Review modal */}
        {rapidReviewOpen && (
          <RapidReviewModal
            status={rapidReviewStatus}
            result={rapidReviewResult}
            busy={rapidReviewBusy}
            error={rapidReviewError}
            onGenerate={generateRapidReview}
            onClose={closeRapidReview}
          />
        )}

        {/* Reset Brain Trace modal — destructive action, type-RESET friction.
            Sits at the highest z-index (32) so it tops every other overlay
            including the cold-start CTA and tour cards. */}
        {resetModalOpen && (
          <ResetBrainTraceModal
            busy={resetBusy}
            report={resetReport}
            onCancel={() => { if (!resetBusy) setResetModalOpen(false); }}
            onConfirm={performReset}
          />
        )}

        {/* Right-click context menu — frosted bar near cursor with the three CTAs */}
        {contextMenu && (
          <NodeContextMenu
            nodeId={contextMenu.nodeId}
            title={contextMenu.title}
            x={contextMenu.x}
            y={contextMenu.y}
            isAttention={!!construction.attention[contextMenu.nodeId]}
            hasAnnotation={!!construction.annotations[contextMenu.nodeId]}
            onAttention={() => toggleAttentionFromMenu(contextMenu.nodeId)}
            onNote={() => openNodeWithNote(contextMenu.nodeId)}
            onLink={() => startLinkingFromMenu(contextMenu.nodeId)}
            onDismiss={dismissContextMenu}
          />
        )}

        {/* Right-click context menu for personal edges — delete only (v1). */}
        {linkContextMenu && (
          <LinkContextMenu
            edgeId={linkContextMenu.edgeId}
            title={linkContextMenu.title}
            x={linkContextMenu.x}
            y={linkContextMenu.y}
            onDelete={() => deletePersonalEdgeFromMenu(linkContextMenu.edgeId)}
            onDismiss={dismissLinkContextMenu}
          />
        )}

        {/* Toast */}
        {toast && (
          <div className={`toast ${toast.warn ? "warn" : ""}`}>{toast.text}</div>
        )}

        {/* Error banner. Suppressed while the route surface is open — its
            z-index (30) sits under the surface (1200), so route-mode errors
            render in-pane inside RouteStudySurface instead (Phase 1). */}
        {error && !routeStudyOpen && (
          <div className="error-banner">
            <strong>Error:</strong> {error}
            <button className="btn-ghost" onClick={() => setError(null)} style={{ marginLeft: 12, color: "var(--red)" }}>dismiss</button>
          </div>
        )}
      </div>
    );
  }

  // ─────────── Session recap card (Stage 3d) ───────────
  // Frosted-glass overlay surfaced at study-panel close when the learner
  // promoted any concept's mastery tier in this sitting. Lists each
  // promotion grouped by new tier (Mastered first, then Competent,
  // Developing, Weak); fast-path graduations get a ✦ marker with a
  // legend line explaining the spaced-retrieval criterion.
  function SessionRecapCard({ crossings, onClose }) {
    if (!crossings || crossings.length === 0) return null;
    // Dedupe by concept (a concept might cross twice if multi-step). Keep
    // the highest tier reached + sticky fast_path flag.
    const byConcept = new Map();
    for (const c of crossings) {
      const prev = byConcept.get(c.concept);
      if (!prev || TIER_RANK[c.after_tier] > TIER_RANK[prev.after_tier]) {
        byConcept.set(c.concept, {
          concept: c.concept,
          after_tier: c.after_tier,
          fast_path: c.fast_path || (prev && prev.fast_path) || false,
        });
      } else if (prev) {
        prev.fast_path = prev.fast_path || c.fast_path;
      }
    }
    const deduped = Array.from(byConcept.values());
    // Group by tier, mastered first.
    const tierOrder = ["mastered", "competent", "developing", "weak"];
    const groups = tierOrder
      .map(t => ({ tier: t, items: deduped.filter(d => d.after_tier === t) }))
      .filter(g => g.items.length > 0);
    const total = deduped.length;
    const anyFastPath = deduped.some(d => d.fast_path);
    return (
      <div className="recap-scrim" onClick={onClose}>
        <div className="recap-card" onClick={e => e.stopPropagation()}>
          <div className="recap-eyebrow">Session recap</div>
          <h2 className="recap-headline">
            {total === 1
              ? "1 concept climbed a tier."
              : `${total} concepts climbed a tier.`}
          </h2>
          <div className="recap-groups">
            {groups.map(g => (
              <div className="recap-group" key={g.tier}>
                <div className="recap-group-title">
                  <span className="recap-dot" style={{ background: TIER_COLOR[g.tier] }} />
                  Promoted to {TIER_LABEL[g.tier]}
                </div>
                <ul className="recap-list">
                  {g.items.map(item => (
                    <li key={item.concept}>
                      {humanizeConceptTitle(item.concept)}
                      {item.fast_path && <span className="recap-marker"> ✦</span>}
                    </li>
                  ))}
                </ul>
              </div>
            ))}
          </div>
          {anyFastPath && (
            <div className="recap-footnote">
              ✦ — graduated via spaced retrieval (3 confident-correct answers across 3 sessions).
            </div>
          )}
          <div className="recap-actions">
            <button className="btn-primary" onClick={onClose}>Continue →</button>
          </div>
        </div>
      </div>
    );
  }

  // ─────────── Catch-up banner (Day 5) ───────────
  // Surfaced at the top of the home screen when /route/current returns
  // catchup_active=true (last_session_at > 7 days ago AND there are concepts
  // whose recall_prob has decayed below the spaced-rep review threshold).
  // Frosted-glass surface, dismissable; does NOT block interaction.
  function CatchupBanner({ daysAway, decayedCount, onDismiss }) {
    const dayWord = daysAway === 1 ? "day" : "days";
    const conceptWord = decayedCount === 1 ? "concept" : "concepts";
    return (
      <div className="catchup-banner">
        <div className="catchup-content">
          <div className="catchup-eyebrow">Welcome back</div>
          <div className="catchup-headline">
            {daysAway > 0 ? `${daysAway} ${dayWord} since your last session.` : "Picking up where you left off."}
            {decayedCount > 0 && (
              <span className="catchup-detail">
                {" "}{decayedCount} {conceptWord} ready for spaced review — pulsing in amber on the map.
              </span>
            )}
          </div>
        </div>
        <button className="catchup-dismiss" onClick={onDismiss} aria-label="Dismiss">
          ×
        </button>
      </div>
    );
  }

  // ─────────── Cold-start CTA overlay ───────────
  // Leads with the central product thesis (we learn how you learn → so you
  // learn faster) rather than the procedural diagnostic mechanic. The
  // diagnostic itself is demoted to mono-line microcopy under the body —
  // it's the *means*, not the headline. The body paragraph names what
  // Brain Trace does that an undifferentiated question bank doesn't:
  // maps your specific mental model, including the confident-but-wrong
  // instincts that hide inside topics you "know."
  function ColdStartOverlay({ onTakeDiagnostic, onDismiss }) {
    return (
      <div className="coldstart-modal">
        <div className="coldstart-card">
          <h2>Welcome to Brain Trace.</h2>
          <p className="thesis">
            We learn how you learn — so you learn faster.
          </p>
          <p className="lead">
            Most question banks drill the average resident. Brain Trace doesn't.
            It maps how <em>your</em> knowledge is actually wired — every weak link,
            every confident-but-wrong instinct — and routes you through them in
            the order that compounds fastest.
          </p>
          <p className="recommendation">
            We recommend you begin mapping with a 30-question diagnostic exam.
          </p>
          <div className="buttons">
            <button className="btn-primary" onClick={onTakeDiagnostic}>
              Begin mapping →
            </button>
            <button className="btn-secondary" onClick={onDismiss}>
              Browse the map first
            </button>
          </div>
        </div>
      </div>
    );
  }

  // ─────────── Diagnostic exam overlay (full-screen, fixed-order Q delivery) ───────────
  function DiagnosticExamOverlay({ session, question, answerResult, onSubmit, onNext }) {
    const total = session?.total_questions || 30;
    const currentPos = question?.diagnostic?.position || 1;
    const dots = [];
    for (let i = 1; i <= total; i++) {
      let cls = "dot";
      if (i < currentPos) cls += " done";
      else if (i === currentPos) cls += " current";
      dots.push(<span key={i} className={cls} />);
    }

    return (
      <div className="fullscreen-overlay">
        <div className="fullscreen-overlay-header">
          <div className="eyebrow" style={{ color: "var(--muted)", fontFamily: "'DM Mono', monospace", fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase" }}>
            Diagnostic exam · seeding your initial map
          </div>
          <h2>Question {currentPos} of {total}</h2>
          <div style={{ fontSize: 13, color: "var(--muted)" }}>
            {question?.diagnostic?.psite_section}
          </div>
          <div className="dot-stepper">{dots}</div>
        </div>
        <div className="fullscreen-overlay-body">
          {question && (
            <InteractionView
              interaction={question}
              answerResult={answerResult}
              onSubmit={onSubmit}
              onNext={onNext}
              onEnd={() => {}}
              endLabel=""
              hideAnalysisStatus={true}
              nextLabel={currentPos === total ? "View results →" : "Next Question →"}
            />
          )}
        </div>
      </div>
    );
  }

  // ─────────── Diagnostic results overlay ───────────
  function DiagnosticResultsOverlay({ results, onDone }) {
    const sections = Object.entries(results.by_section || {})
      .sort((a, b) => a[1].pct - b[1].pct);  // worst first

    return (
      <div className="fullscreen-overlay">
        <div className="fullscreen-overlay-header">
          <div className="eyebrow" style={{ color: "var(--muted)", fontFamily: "'DM Mono', monospace", fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase" }}>
            Diagnostic complete
          </div>
          <h2>Your initial map.</h2>
          <div style={{ fontSize: 14, color: "var(--muted)" }}>
            {results.total_correct} of {results.total_answered} correct overall
            {" · "}
            {Math.round(100 * results.total_correct / Math.max(1, results.total_answered))}%
          </div>
        </div>
        <div className="fullscreen-overlay-body">
          <div className="card">
            <div className="eyebrow" style={{ marginBottom: 16, fontFamily: "'DM Mono', monospace", fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--muted)" }}>
              Per-section breakdown · weakest first
            </div>
            {sections.map(([section, data]) => {
              const isRecommended = section === results.recommended_starting_section;
              const fillColor = data.pct < 30 ? "#B5634C"
                : data.pct < 60 ? "#B5894C"
                : data.pct < 80 ? "#7A8C5C"
                : "#2D5C4F";
              return (
                <div className={`section-bar-row${isRecommended ? " recommended" : ""}`} key={section}>
                  <div className="label">{section}</div>
                  <div className="bar-track">
                    <div className="bar-fill" style={{ width: `${data.pct}%`, background: fillColor }} />
                  </div>
                  <div className="pct">{data.correct}/{data.total} · {data.pct}%</div>
                </div>
              );
            })}
          </div>

          {results.next_node && (
            <div className="card">
              <div className="eyebrow" style={{ marginBottom: 8, fontFamily: "'DM Mono', monospace", fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--muted)" }}>
                Suggested starting concept
              </div>
              {/* Fix D — concept-substrate framing. Headline reads as the
                  target *concept*; the parent topic appears as smaller context.
                  Falls back to the topic title when concept fields are absent
                  (older response shape, vault gaps). */}
              <div style={{ fontFamily: "'Instrument Serif', serif", fontSize: 24, marginBottom: 4 }}>
                {results.next_node.concept_title || results.next_node.concept_tag || results.next_node.title}
              </div>
              {(results.next_node.concept_title || results.next_node.concept_tag) && (
                <div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 4, fontFamily: "'DM Mono', monospace", textTransform: "uppercase", letterSpacing: "0.06em" }}>
                  in {results.next_node.title}
                </div>
              )}
              <div style={{ fontSize: 13, color: "var(--muted)", marginBottom: 10 }}>
                {results.next_node.psite_section} · {results.next_node.tested_frequency || "—"} board frequency
                {results.recommended_starting_section
                  ? ` · weakest section was ${results.recommended_starting_section}`
                  : ""}
                {results.next_node.confidence_band && results.next_node.confidence_band !== "high"
                  ? ` · ${results.next_node.confidence_band} confidence`
                  : ""}
              </div>
              <div style={{ fontSize: 13, color: "var(--muted)", lineHeight: 1.55, fontStyle: "italic" }}>
                {results.next_node.rationale}
              </div>
              {/* Honest-promise explainer (2026-07-29): the route optimizer has no
                  section term and re-ranks continuously (per-answer nudges + async
                  analyzer corrections), so this card must read as a suggestion —
                  never a guarantee of position 1. Keep copy in sync with the
                  planner's actual scoring terms (weakness/blueprint/novelty/decay). */}
              <div style={{ fontSize: 12.5, color: "var(--muted)", lineHeight: 1.55, marginTop: 10 }}>
                Your route re-plans after every answer: concepts are ranked by a blend of how
                weak they are, how often the boards test them, what you haven't seen yet, and
                what you're starting to forget — so the weakest concept isn't always first,
                and today's top pick can shift as your diagnostic results settle in.
              </div>
            </div>
          )}

          {results.estimated_sessions_to_board_ready && (
            <div style={{ fontSize: 12, color: "var(--muted)", textAlign: "center", marginTop: 16, fontFamily: "'DM Mono', monospace" }}>
              Estimated board-ready in {results.estimated_sessions_to_board_ready.lower}–{results.estimated_sessions_to_board_ready.upper} sessions at your current pace.
            </div>
          )}

          <div style={{ marginTop: 28, textAlign: "center" }}>
            <button className="btn-primary" onClick={onDone}>
              {results.next_node
                ? `Begin adaptive study at ${results.next_node.title} →`
                : "Begin adaptive study →"}
            </button>
          </div>
        </div>
      </div>
    );
  }

  // ─────────── Mastery legend ───────────
  // ─────────── Section filter pills (Slice D-polish: View by Section) ───────────
  // Renders a thin pill row centered above the graph. "All" returns to the
  // unfiltered view; clicking a section pill dims non-matching nodes to 30%
  // (in GraphView's nodeColor callback). Sections are pulled live from the
  // structure so the row stays in sync with vault expansion.
  function SectionFilter({ structure, activeSection, highlightedSection, onChange, onResultClick }) {
    const [searchMode, setSearchMode] = useState(false);
    const [query, setQuery] = useState("");
    const [results, setResults] = useState(null);
    const [loading, setLoading] = useState(false);
    const debounceRef = useRef(null);
    const inputRef = useRef(null);

    const sections = useMemo(() => {
      const seen = new Map();
      (structure?.nodes || []).forEach(n => {
        if (!n.psite_section) return;
        seen.set(n.psite_section, (seen.get(n.psite_section) || 0) + 1);
      });
      return Array.from(seen.entries()).sort((a, b) => b[1] - a[1]);  // most-populous first
    }, [structure]);

    // Snake-case → Title Case display labels
    const titleize = (s) => s.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");

    // Debounced search fetch
    useEffect(() => {
      if (!searchMode) return;
      if (debounceRef.current) clearTimeout(debounceRef.current);
      const trimmed = query.trim();
      if (trimmed.length < 2) {
        setResults(null);
        setLoading(false);
        return;
      }
      setLoading(true);
      debounceRef.current = setTimeout(async () => {
        try {
          const r = await api(`/search?q=${encodeURIComponent(trimmed)}&limit=8`);
          setResults(r);
        } catch (e) {
          setResults({ nodes: [], questions: [], notes: [], _error: e.message });
        } finally {
          setLoading(false);
        }
      }, 220);
      return () => debounceRef.current && clearTimeout(debounceRef.current);
    }, [query, searchMode]);

    // ESC closes search
    useEffect(() => {
      if (!searchMode) return;
      const onKey = (e) => { if (e.key === "Escape") closeSearch(); };
      window.addEventListener("keydown", onKey);
      return () => window.removeEventListener("keydown", onKey);
    }, [searchMode]);

    const openSearch = () => {
      setSearchMode(true);
      setTimeout(() => inputRef.current && inputRef.current.focus(), 50);
    };
    const closeSearch = () => {
      setSearchMode(false);
      setQuery("");
      setResults(null);
      setLoading(false);
    };

    const pickResult = (kind, payload) => {
      const targetNodeId = payload.node_id;
      closeSearch();
      if (targetNodeId && onResultClick) {
        onResultClick({ kind, node_id: targetNodeId, payload });
      }
    };

    if (!sections.length) return null;

    const hasAnyResults = results && (
      (results.nodes && results.nodes.length) ||
      (results.questions && results.questions.length) ||
      (results.notes && results.notes.length)
    );

    return (
      <div
        className={`section-filter${searchMode ? " search-active" : ""}`}
        role={searchMode ? "search" : "tablist"}
        aria-label={searchMode ? "Search the brain" : "Filter graph by PSITE section"}
      >
        {!searchMode ? (
          <>
            <button
              className="pill search-trigger"
              onClick={openSearch}
              title="Search the brain (topics, questions, notes)"
              aria-label="Search the brain"
            >
              <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
                <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5"/>
                <line x1="10.5" y1="10.5" x2="14" y2="14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
              </svg>
            </button>
            <button
              className={`pill all-pill${activeSection === null ? " active" : ""}`}
              onClick={() => onChange(null)}
              title="Show all sections"
              role="tab"
              aria-selected={activeSection === null}
            >
              All
            </button>
            {sections.map(([sec, count]) => {
              const isActive = activeSection === sec;
              const isHighlighted = highlightedSection === sec;
              let cls = "pill";
              if (isActive) cls += " active";
              if (isHighlighted && !isActive) cls += " tour-highlight";
              return (
                <button
                  key={sec}
                  className={cls}
                  onClick={() => onChange(activeSection === sec ? null : sec)}
                  title={`${titleize(sec)} · ${count} nodes`}
                  role="tab"
                  aria-selected={isActive}
                >
                  {titleize(sec)}
                </button>
              );
            })}
          </>
        ) : (
          <>
            <div className="section-search">
              <svg className="search-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
                <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5"/>
                <line x1="10.5" y1="10.5" x2="14" y2="14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
              </svg>
              <input
                ref={inputRef}
                type="text"
                value={query}
                onChange={e => setQuery(e.target.value)}
                placeholder="Search topics, questions, your notes…"
                aria-label="Search the brain"
              />
              <button className="close-btn" onClick={closeSearch} title="Close search (Esc)" aria-label="Close search">×</button>
            </div>
            {searchMode && query.trim().length >= 2 && (
              <div className="search-results" role="listbox">
                {loading && !results && (
                  <div className="empty-state">Searching…</div>
                )}
                {results && !hasAnyResults && (
                  <div className="empty-state">No matches for &ldquo;{results.query}&rdquo;.</div>
                )}
                {results && results.nodes && results.nodes.length > 0 && (
                  <>
                    <div className="group-header">Topics</div>
                    {results.nodes.map(n => (
                      <button
                        key={`node-${n.node_id}`}
                        className="result-row"
                        onClick={() => pickResult("node", n)}
                        role="option"
                      >
                        <span className="kind-badge" title="Topic">T</span>
                        <span className="body">
                          <span className="title">{n.title}</span>
                          <span className="subtitle">
                            {titleize(n.psite_section || "")}
                            {n.difficulty ? ` · ${n.difficulty}` : ""}
                            {n.tested_frequency === "high" ? " · ★ high-yield" : ""}
                          </span>
                        </span>
                      </button>
                    ))}
                  </>
                )}
                {results && results.questions && results.questions.length > 0 && (
                  <>
                    <div className="group-header">Questions</div>
                    {results.questions.map(qr => (
                      <button
                        key={`q-${qr.content_hash}`}
                        className="result-row"
                        onClick={() => pickResult("question", qr)}
                        role="option"
                        disabled={!qr.node_id}
                        style={!qr.node_id ? { opacity: 0.55, cursor: "not-allowed" } : undefined}
                        title={!qr.node_id ? "This question isn't mapped to a topic node yet" : undefined}
                      >
                        <span className="kind-badge" title="Question">Q</span>
                        <span className="body">
                          <span className="title">
                            {qr.node_title || "Unmapped question"}
                          </span>
                          <span className="subtitle">{qr.stem_excerpt}</span>
                        </span>
                      </button>
                    ))}
                  </>
                )}
                {results && results.notes && results.notes.length > 0 && (
                  <>
                    <div className="group-header">Your notes</div>
                    {results.notes.map(nt => (
                      <button
                        key={`note-${nt.node_id}`}
                        className="result-row"
                        onClick={() => pickResult("note", nt)}
                        role="option"
                      >
                        <span className="kind-badge" title="Personal note">N</span>
                        <span className="body">
                          <span className="title">{nt.node_title}</span>
                          <span className="subtitle">{nt.snippet}</span>
                        </span>
                      </button>
                    ))}
                  </>
                )}
              </div>
            )}
          </>
        )}
      </div>
    );
  }

  // ─────────── Onboarding tutorial overlay (Slice D-polish v3) ───────────
  // 6-step sequence opened by the learning-philosophy intro card, fired
  // first-time-per-session after any cold-start CTA is dismissed. Step 2
  // synchronizes a section-cluster cycle in the graph (see GraphView's
  // tutorialClusterIndex prop). Step 4 amplifies route position-1's halo.
  function TutorialOverlay({ step, onAdvance, onSkip, dontShowAgain, onToggleDontShowAgain }) {
    const steps = [
      {
        n: 1,
        position: { top: "12%", left: "50%", transform: "translateX(-50%)" },
        size: "wide",
        title: "The learning science behind your Brain Trace.",
        body: "Brain Trace is grounded in decades of research on how durable medical knowledge is actually formed and maintained.",
        twoColumnsLead: "Your learning route is guided by the following principles.",
        twoColumns: [
          [
            { text: "Spaced retrieval" },
            { text: "Adaptive sequencing" },
            { text: "Content misconceptions" },
          ],
          [
            { text: "Concept interleaving" },
            { text: "Confidence ratings" },
            { text: "Active construction of your map" },
          ],
        ],
        tagline: "We learn how you learn — and route you to mastery in the least time possible.",
      },
      {
        // 2a — Cluster cycle. Camera sequences through the 5 PSITE sections,
        // pill highlights in sync. Driven by the clusterCycleSection state.
        n: 2,
        position: { bottom: "80px", left: "50%", transform: "translateX(-50%)" },
        size: "wide",
        title: "This is your knowledge map.",
        body: "635 nodes from the Plastic Surgery Brain, grouped into five clinical domains — Breast & Cosmetic, Hand & Extremities, Core Surgical Principles, Craniomaxillofacial, and Comprehensive Integument. Watch the pills above and the camera move: each cluster lights up in turn so you can see how the map is organized.",
      },
      {
        // 2b — Edges (connections). Brief inline 2-node sketch.
        n: 3,
        position: { top: "20%", left: "50%", transform: "translateX(-50%)" },
        size: "wide",
        title: "Each node is a topic — edges connect related topics.",
        body: "Every node on the map stands for a single topic. Its color and size come from a running average of how you're doing on the concrete concepts that make up that topic — and that running score is what drives your study route. The connecting lines, called edges, are clinically meaningful links between topics: when you flag, study, or master one node, its edges tell the planner which neighboring topics to consider next.",
        demo: "edges",
      },
      {
        // 4 — Color demo + compressed mastery-tier table at the bottom.
        // (Steven moved Card 10's content into this card so the tour ends sooner.)
        n: 4,
        position: { top: "12%", left: "50%", transform: "translateX(-50%)" },
        size: "wide",
        title: "Color shows your current mastery on each node.",
        body: "Watch this node cycle through the five mastery tiers — gray (unattempted) → clay (weak) → amber (developing) → sage (competent) → deep green (mastered). Nodes on your map move through these colors as you answer.",
        demo: "color",
        masteryTable: true,
      },
      {
        // Authoring. Single-column emoji bullets + Rapid Review callout. (Was card 7.)
        n: 5,
        position: { top: "14%", left: "50%", transform: "translateX(-50%)" },
        size: "wide",
        title: "You can do more than navigate — you can author the map.",
        body: "Click any node to open the study panel, or right-click the node to see three controls.",
        iconBullets: [
          { icon: "🚩", title: "Flag for attention",
            desc: "promotes a node onto your route even when its mastery would otherwise hide it." },
          { icon: "📝", title: "Add a note",
            desc: "pin clinical pearls, mnemonics, or things to revisit privately to a node." },
          { icon: "↗", title: "Draw a personal connection",
            desc: "link two nodes the canonical map doesn't link — clinical insights, your own associations." },
        ],
        callout: "✦ The notes you add here feed into Generate Rapid Review Notes — Brain Trace's once-weekly synthesis of your weak topics + personal annotations into a downloadable Cornell-format cram sheet.",
      },
      {
        // Combined Start + Diagnostic. Two concise bullets. (Was card 8.)
        n: 6,
        position: { bottom: "200px", left: "50%", transform: "translateX(-50%)" },
        size: "wide",
        title: "Start your study adventure.",
        body: "Two ways to begin:",
        iconBullets: [
          { icon: "🚀", title: "Click any node",
            desc: "to study it directly, or hit the primary CTA below to follow the system's #1 route recommendation." },
          { icon: "🧭", title: "Take the 30-question diagnostic",
            desc: "to seed your map across all five clinical domains. ~30 minutes; recalibrate any time." },
        ],
      },
    ];
    const current = steps[step - 1];
    if (!current) return null;
    const bubbleClass = "tutorial-bubble" + (current.size === "wide" ? " wide" : "");
    return (
      <div className="tutorial-scrim" onClick={onSkip}>
        <div
          className={bubbleClass}
          style={current.position}
          onClick={(e) => e.stopPropagation()}
        >
          <div className="step">Tour · {current.n} of 6</div>
          <h3>{current.title}</h3>
          {current.demo && <TutorialDemo kind={current.demo} />}
          {current.body && <p>{current.body}</p>}
          {current.twoColumnsLead && (
            <p className="tutorial-twocol-lead">{current.twoColumnsLead}</p>
          )}
          {current.twoColumns && (
            <div className="tutorial-twocol">
              {current.twoColumns.map((col, ci) => (
                <ul key={ci} className="tutorial-twocol-list">
                  {col.map((item, ii) => (
                    <li key={ii}>
                      {item.lead && <strong>{item.lead}</strong>}
                      {item.lead ? " " : ""}{item.text}
                    </li>
                  ))}
                </ul>
              ))}
            </div>
          )}
          {current.bodyAfterColumns && (
            <p className="tutorial-body-after">{current.bodyAfterColumns}</p>
          )}
          {current.iconBullets && (
            <ul className="tutorial-icon-bullets">
              {current.iconBullets.map((b, i) => (
                <li key={i}>
                  <span className="tutorial-icon">{b.icon}</span>
                  <span><strong>{b.title}</strong> — {b.desc}</span>
                </li>
              ))}
            </ul>
          )}
          {current.callout && (
            <div className="tutorial-callout">{current.callout}</div>
          )}
          {current.masteryTable && <MasteryTierTable />}
          {current.tiers && (
            <ul className="tier-list">
              {current.tiers.map((t) => (
                <li key={t.label}>
                  <strong>{t.label}</strong> — {t.desc}
                </li>
              ))}
            </ul>
          )}
          {current.tagline && (
            <>
              <div className="tutorial-divider" />
              <div className="tutorial-tagline">{current.tagline}</div>
            </>
          )}
          {current.n === 6 && (
            <label
              style={{
                display: "flex", alignItems: "center", gap: 8,
                fontSize: 13, color: "var(--bt-muted, #6B6B67)",
                margin: "10px 0 2px", cursor: "pointer",
              }}
            >
              <input
                type="checkbox"
                checked={!!dontShowAgain}
                onChange={(e) => onToggleDontShowAgain(e.target.checked)}
                style={{ cursor: "pointer" }}
              />
              <span>Don't show this again</span>
            </label>
          )}
          <div className="actions">
            <button className="btn-ghost" onClick={onSkip}>Skip tour</button>
            <button className="btn-primary" style={{ padding: "8px 16px", fontSize: 13 }} onClick={onAdvance}>
              {current.n === 6 ? "Got it →" : "Next →"}
            </button>
          </div>
        </div>
      </div>
    );
  }

  // ─────────── Tutorial demos (Slice D-polish v4) ───────────
  // Inline SVG animations rendered inside the Card 2b/2c/2d bubbles. Each
  // demo is a static-size canvas that runs an interval-driven state animation
  // — small enough to feel tactile, big enough to read at a glance.
  function TutorialDemo({ kind }) {
    if (kind === "edges") return <EdgesDemo />;
    if (kind === "color") return <ColorCycleDemo />;
    if (kind === "size") return <SizeCycleDemo />;
    return null;
  }

  // Edges demo — two nodes connected by a line, with an accent pulse traveling
  // along the line so the user can see "edge = relationship between nodes."
  function EdgesDemo() {
    const [t, setT] = useState(0);
    useEffect(() => {
      const id = setInterval(() => setT((v) => (v + 0.025) % 1), 30);
      return () => clearInterval(id);
    }, []);
    // Two endpoints; a small "pulse" dot at fraction t along the segment
    const A = { x: 60, y: 50 };
    const B = { x: 240, y: 50 };
    const px = A.x + (B.x - A.x) * t;
    const py = A.y + (B.y - A.y) * t;
    return (
      <div className="tutorial-demo">
        <svg width="300" height="100" viewBox="0 0 300 100">
          {/* Edge line */}
          <line x1={A.x} y1={A.y} x2={B.x} y2={B.y}
                stroke="#6B6B67" strokeWidth="1.5" />
          {/* Accent pulse traveling along the edge */}
          <circle cx={px} cy={py} r="4"
                  fill="rgba(45, 92, 79, 0.85)" />
          {/* Two nodes */}
          <circle cx={A.x} cy={A.y} r="10" fill="#7A8C5C" stroke="#FFF" strokeWidth="1.5" />
          <circle cx={B.x} cy={B.y} r="10" fill="#B5894C" stroke="#FFF" strokeWidth="1.5" />
          {/* Subtle labels */}
          <text x={A.x} y={A.y + 28} textAnchor="middle"
                fontFamily="DM Mono, monospace" fontSize="9" fill="#6B6B67">node A</text>
          <text x={B.x} y={B.y + 28} textAnchor="middle"
                fontFamily="DM Mono, monospace" fontSize="9" fill="#6B6B67">node B</text>
          <text x={150} y={42} textAnchor="middle"
                fontFamily="DM Mono, monospace" fontSize="9" fill="#6B6B67">edge</text>
        </svg>
      </div>
    );
  }

  // Color-cycle demo — single node fading through the 5 mastery tiers in
  // sequence. The user can read off the color → meaning mapping at a glance
  // and see what node-color transitions look like in motion.
  function ColorCycleDemo() {
    const tiers = [
      { c: "#D6D6D2", label: "unattempted" },
      { c: "#B5634C", label: "weak" },
      { c: "#B5894C", label: "developing" },
      { c: "#7A8C5C", label: "competent" },
      { c: "#2D5C4F", label: "mastered" },
    ];
    const [idx, setIdx] = useState(0);
    useEffect(() => {
      const id = setInterval(() => setIdx((v) => (v + 1) % tiers.length), 1100);
      return () => clearInterval(id);
    }, []);
    const tier = tiers[idx];
    return (
      <div className="tutorial-demo">
        <svg width="300" height="100" viewBox="0 0 300 100">
          <circle cx="150" cy="50" r="22"
                  fill={tier.c}
                  stroke="#FFFFFF" strokeWidth="2"
                  style={{ transition: "fill 600ms cubic-bezier(0.16, 1, 0.3, 1)" }} />
          <text x="150" y="86" textAnchor="middle"
                fontFamily="DM Mono, monospace" fontSize="10" fill="#6B6B67"
                style={{ transition: "all 300ms" }}>
            {tier.label}
          </text>
        </svg>
        <div className="tutorial-demo-row">
          {tiers.map((t, i) => (
            <div
              key={t.c}
              className={`tutorial-demo-dot${i === idx ? " active" : ""}`}
              style={{ background: t.c }}
              title={t.label}
            />
          ))}
        </div>
      </div>
    );
  }

  // Size-cycle demo — node radius pulses smoothly between min and max so the
  // user can SEE that the larger a node renders, the more often it's tested.
  function SizeCycleDemo() {
    const [t, setT] = useState(0);
    useEffect(() => {
      // ~10 second per full sine cycle — slow + relaxed, not dizzying.
      const id = setInterval(() => setT((v) => (v + 0.005) % 1), 50);
      return () => clearInterval(id);
    }, []);
    // 4px (low frequency) ↔ 14px (high frequency)
    const phase = Math.sin(t * 2 * Math.PI);
    const r = 9 + phase * 5;
    const label = phase > 0.4 ? "high frequency" : phase < -0.4 ? "low frequency" : "medium frequency";
    return (
      <div className="tutorial-demo">
        <svg width="300" height="100" viewBox="0 0 300 100">
          <circle cx="150" cy="50" r={r.toFixed(2)}
                  fill="#7A8C5C" stroke="#FFFFFF" strokeWidth="2" />
          <text x="150" y="86" textAnchor="middle"
                fontFamily="DM Mono, monospace" fontSize="10" fill="#6B6B67">
            {label}
          </text>
        </svg>
      </div>
    );
  }

  // Compact mastery-tier table — rendered in Card 4 below the color demo.
  // Compresses what was previously a separate Card 10 "tiers" list.
  function MasteryTierTable() {
    const rows = [
      { c: "#D6D6D2", tier: "Unattempted",   m: "—",          desc: "never seen — clay-gray." },
      { c: "#B5634C", tier: "Weak",          m: "< 0.30",     desc: "wrong on most attempts; needs foundational exposure." },
      { c: "#B5894C", tier: "Developing",    m: "0.30–0.60",  desc: "partial understanding; some correct, some careless errors." },
      { c: "#7A8C5C", tier: "Competent",     m: "0.60–0.70",  desc: "correct most of the time; spaced reviews verify retention." },
      { c: "#2D5C4F", tier: "Mastered",      m: "≥ 0.70",     desc: "consistent across varied questions + teach-back verifies mechanism. ≥2 attempts." },
    ];
    return (
      <>
        <div style={{ fontSize: 11, color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 6 }}>
          Fill — overall mastery on the topic
        </div>
        <table className="tutorial-tier-table">
          <thead>
            <tr><th>Tier</th><th>Mastery</th><th>What it means</th></tr>
          </thead>
          <tbody>
            {rows.map((r) => (
              <tr key={r.tier}>
                <td>
                  <span className="tier-dot" style={{ background: r.c }} />
                  {r.tier}
                </td>
                <td className="mono">{r.m}</td>
                <td>{r.desc}</td>
              </tr>
            ))}
          </tbody>
        </table>
        {/* Fix D — render rule β. Canonical user-facing explanation of the
            dual-band rendering rule. The popup is dynamically sized to fit
            the viewport (HoverTooltip computes max-height from the
            wrapper's actual rect), so the full three-paragraph form lives
            here without overflow risk. */}
        <div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid var(--rule)" }}>
          <div style={{ fontSize: 11, color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 6 }}>
            Halo — hidden weakness inside the topic
          </div>
          <p style={{ margin: "0 0 8px", fontSize: 13, lineHeight: 1.55 }}>
            <strong>Fill color</strong> tells you how mastered you are <em>on
            average</em> across this topic. Green means you've answered most
            concepts inside well; red means most are weak.
          </p>
          <p style={{ margin: "0 0 8px", fontSize: 13, lineHeight: 1.55 }}>
            <strong>Halo (outer ring)</strong> tells you how weak the
            <em> single weakest concept</em> inside this topic is. A green node
            with a bright halo means you're competent overall but at least one
            specific concept underneath needs work. The halo is what surfaces
            hidden weaknesses you'd otherwise miss.
          </p>
          <p style={{ margin: 0, fontSize: 13, lineHeight: 1.55 }}>
            <strong>Together:</strong> a green-fill faint-halo node is genuinely
            mastered; a green-fill bright-halo node hides a weak spot worth
            revisiting; a red-fill bright-halo node is weak everywhere.
          </p>
        </div>
      </>
    );
  }

  // ─────────── HoverTooltip ───────────
  // Reusable hover-tooltip primitive. Wraps any child element and surfaces a
  // frosted-glass rationale card on hover — Instrument Serif title, DM Sans body,
  // optional rich content (e.g., the MasteryTierTable on the Mastery legend).
  //
  // Behaviors baked in:
  //   - 350ms hover delay before the card appears (avoids incidental flicker on
  //     mouse-fly-by; standard pattern for non-essential affordances)
  //   - Skipped entirely on touch devices (no hover capability) — children render
  //     bare. Touch users get the native title= tooltip on long-press if it exists.
  //   - Edge-aware position: defaults to "above" the wrapped element; flips to
  //     "below" if the wrapper is in the top 200px of the viewport (would clip).
  //   - Optional right-anchor mode for elements at the viewport's right edge
  //     (the Mastery legend) so the card hugs the right side instead of
  //     overflowing past the viewport.
  //
  // Cohesive-ecosystem note: the component is self-contained and uses only
  // CSS tokens already shared with incise_app (Instrument Serif, DM Sans, --rule,
  // --easing). Porting to the alpha tab is a straight copy.
  function HoverTooltip({ children, eyebrow, title, body, richBody, wide, rightAnchor, leftAnchor, sideLeft, blockWrapper, wrapperStyle }) {
    const [visible, setVisible] = useState(false);
    const [actualPosition, setActualPosition] = useState("above");
    // Tooltip height is measured from the wrapper's actual bounding rect at
    // hover-open time. Fixed CSS max-heights kept overflowing because the
    // legend's content (and therefore its rendered height) varies with
    // section coverage — better to measure once and apply inline.
    const [maxHeight, setMaxHeight] = useState(null);
    const wrapperRef = useRef(null);
    const timerRef = useRef(null);

    // Touch detection — only run hover code on devices that genuinely have hover
    // capability. matchMedia is consulted once per render; safe to memoize but
    // not a performance concern at this volume.
    const isHoverable = useMemo(() => {
      if (typeof window === "undefined" || !window.matchMedia) return true;
      return window.matchMedia("(hover: hover)").matches;
    }, []);

    if (!isHoverable) return children;

    const handleEnter = () => {
      if (timerRef.current) clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => {
        const rect = wrapperRef.current?.getBoundingClientRect();
        // sideLeft mode positions the popup horizontally beside the wrapper
        // with a fixed 80px top offset (clears the pill row). Cap the
        // height at 75% of the viewport so the popup is visibly bounded
        // and the scroll fallback engages early on tall content.
        if (sideLeft) {
          setActualPosition("side-left");
          const room = (window.innerHeight - 80 - 24) * 0.75;
          setMaxHeight(Math.max(200, room));
          setVisible(true);
          return;
        }
        // Edge-aware: if too near the viewport top, flip the card below the wrapper.
        const willPositionBelow = rect && rect.top < 220;
        setActualPosition(willPositionBelow ? "below" : "above");
        // Compute the actual room available given the wrapper's real
        // position. 30px buffer covers the 10px gap + 20px breathing room
        // from the viewport edge. Floor at 200 so a tooltip never collapses
        // to a sliver on tiny windows.
        if (rect) {
          const room = willPositionBelow
            ? window.innerHeight - rect.bottom - 30
            : rect.top - 30;
          setMaxHeight(Math.max(200, room));
        }
        setVisible(true);
      }, 350);
    };

    // Hide is delayed slightly so the cursor can transit from the wrapper
    // onto the tooltip body (e.g. to scroll a tall popup) without dismissing.
    // The tooltip's own onMouseEnter cancels the timer.
    const handleLeave = () => {
      if (timerRef.current) clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => setVisible(false), 120);
    };

    const handleTooltipEnter = () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
        timerRef.current = null;
      }
    };

    const classes = [
      "hover-tooltip",
      actualPosition,
      wide ? "wide" : "",
      rightAnchor ? "right-anchor" : "",
      leftAnchor ? "left-anchor" : "",
    ].filter(Boolean).join(" ");

    // Inline style for the tooltip — applies the dynamically-measured
    // max-height (if available) and re-enables pointer events on wide
    // tooltips so the wheel reaches the internal scroll container.
    const tooltipStyle = {
      ...(maxHeight ? { maxHeight: `${maxHeight}px`, overflowY: "auto" } : {}),
      ...(wide ? { pointerEvents: "auto" } : {}),
    };

    return (
      <span
        ref={wrapperRef}
        className={`hover-tooltip-wrapper${blockWrapper ? " block" : ""}`}
        style={wrapperStyle}
        onMouseEnter={handleEnter}
        onMouseLeave={handleLeave}
        onFocus={handleEnter}
        onBlur={handleLeave}
      >
        {children}
        {visible && (
          <div
            className={classes}
            role="tooltip"
            style={tooltipStyle}
            onMouseEnter={handleTooltipEnter}
            onMouseLeave={handleLeave}
          >
            {eyebrow && <div className="hover-tooltip-eyebrow">{eyebrow}</div>}
            {title && <div className="hover-tooltip-title">{title}</div>}
            {body && <div className="hover-tooltip-body">{body}</div>}
            {richBody}
          </div>
        )}
      </span>
    );
  }

  // ─────────── Mobile viewport hook ───────────
  // ≤768px reflows the floating chrome (top bar, footer, legend, mastery rail)
  // for phone use. Matches the PWA host's useIsMobile(768) breakpoint and the
  // @media (max-width: 768px) blocks in the stylesheet. Desktop is untouched.
  function useViewportIsMobile(bp = 768) {
    const [m, setM] = useState(() => window.matchMedia(`(max-width: ${bp}px)`).matches);
    useEffect(() => {
      const mq = window.matchMedia(`(max-width: ${bp}px)`);
      const fn = (e) => setM(e.matches);
      mq.addEventListener("change", fn);
      return () => mq.removeEventListener("change", fn);
    }, [bp]);
    return m;
  }

  function Legend() {
    const tiers = [
      { c: "#D6D6D2", l: "Unattempted" },
      { c: "#B5634C", l: "Weak (< 0.3)" },
      { c: "#B5894C", l: "Developing" },
      { c: "#7A8C5C", l: "Competent" },
      { c: "#2D5C4F", l: "Mastered (≥ 0.7)" },
    ];
    return (
      <div className="legend">
        <div className="title">Fill — mastery</div>
        {tiers.map(t => (
          <div className="legend-row" key={t.c}>
            <span className="legend-dot" style={{ background: t.c }} />
            <span>{t.l}</span>
          </div>
        ))}
        {/* Fix D — render rule β second band: halo intensity. The hidden-
            weakness halo is a separate channel; the legend visibly shows BOTH
            scales so a user reading the graph cold knows the two encodings
            without opening the popup. */}
        <div className="title" style={{ marginTop: 10 }}>Halo — hidden weakness</div>
        <div className="legend-row">
          <span
            className="legend-dot"
            style={{
              background: "transparent",
              border: "1px solid rgba(181, 99, 76, 0.10)",
              boxShadow: "0 0 0 1px rgba(181, 99, 76, 0.06)",
            }}
          />
          <span>Faint — weakest concept is strong</span>
        </div>
        <div className="legend-row">
          <span
            className="legend-dot"
            style={{
              background: "transparent",
              border: "1.5px solid rgba(181, 99, 76, 0.42)",
            }}
          />
          <span>Bright — a concept inside is weak</span>
        </div>
      </div>
    );
  }

  // ─────────── Graph view ───────────
  // ── Brain Trace settled-layout cache (perf: render the graph already-laid-out) ─
  // The force layout was recomputed from scratch on every open (nodes had no
  // initial x/y), so ~600 nodes visibly sprang into place — the "whiplash". We now
  // cache settled positions in localStorage keyed by a topology signature (sorted
  // node-ids + edge count). On a cache hit nodes mount pre-positioned and the
  // simulation is skipped (warmup=0, cooldown=0) → instant, motion-free, and
  // pixel-stable across opens. Topology change → signature mismatch → one-time
  // re-settle, then re-cache. No new deps; pure localStorage + existing d3-force.
  const _BT_WARMUP_TICKS = 120;  // matches the prior visible cooldownTicks settle
  function _btTopoSig(structure) {
    const ids = (structure?.nodes || []).map(n => n.id).sort();
    const ec = (structure?.edges || []).length;
    const s = ids.join("|") + "#" + ec;
    let h = 0;
    for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
    return "btLayout:" + (h >>> 0).toString(36) + "." + ids.length + "." + ec;
  }
  function _btLoadLayout(sig) {
    try { const raw = localStorage.getItem(sig); return raw ? JSON.parse(raw) : null; }
    catch (_e) { return null; }
  }
  function _btSaveLayout(sig, posById) {
    try { localStorage.setItem(sig, JSON.stringify(posById)); } catch (_e) { /* quota — non-fatal */ }
  }

  // Structure (topology) cache — the 635-node graph is static across users and the
  // endpoint's own docstring calls it cacheable. Stale-while-revalidate: paint the
  // cached topology instantly on open, then swap to fresh only if it actually changed.
  const _BT_STRUCTURE_KEY = "btStructureCache:v1";
  function _btLoadStructure() {
    try { const raw = localStorage.getItem(_BT_STRUCTURE_KEY); return raw ? JSON.parse(raw) : null; }
    catch (_e) { return null; }
  }
  function _btSaveStructure(struct) {
    try { localStorage.setItem(_BT_STRUCTURE_KEY, JSON.stringify(struct)); } catch (_e) { /* quota — non-fatal */ }
  }

  // Route-metrics cache — SCALARS ONLY (deliberately drops active_route /
  // decayed_node_ids so a stale cache can never mis-target the cinematic, the decay
  // pulse, or the start-session default). Lets the header stats strip paint last-known
  // values instantly on repeat opens while the slow planner fetch revalidates.
  const _BT_ROUTEMETRICS_KEY = "btRouteMetricsCache:v1";
  function _btLoadRouteMetrics() {
    try { const r = localStorage.getItem(_BT_ROUTEMETRICS_KEY); return r ? JSON.parse(r) : null; }
    catch (_e) { return null; }
  }
  function _btSaveRouteMetrics(rm) {
    if (!rm) return;
    const { concepts_mastered_count, concepts_in_focus_count, total_traces_count,
            pct_mastery, estimated_sessions_to_mastery } = rm;
    try { localStorage.setItem(_BT_ROUTEMETRICS_KEY, JSON.stringify({
      concepts_mastered_count, concepts_in_focus_count, total_traces_count,
      pct_mastery, estimated_sessions_to_mastery,
    })); } catch (_e) { /* quota — non-fatal */ }
  }

  // Graph-state (per-node mastery) cache — drives first-frame node COLORS on repeat
  // opens. Stale-while-revalidate exactly like the structure cache: paint cached tiers
  // instantly, then fresh /graph/state overrides. Trimmed to the renderer-only fields
  // (mastery_estimate/attempts/correct/min_concept_mastery/weakest_concept_tag) to keep
  // the blob small. Versioned key so a per_node schema change invalidates old blobs.
  const _BT_GRAPHSTATE_KEY = "btGraphStateCache:v1";
  function _btLoadGraphState() {
    try {
      const raw = localStorage.getItem(_BT_GRAPHSTATE_KEY);
      const obj = raw ? JSON.parse(raw) : null;
      // Shape-guard: a corrupt/truncated blob must degrade to all-gray, not crash.
      return obj && obj.per_node && typeof obj.per_node === "object" ? obj : null;
    } catch (_e) { return null; }
  }
  function _btSaveGraphState(gs) {
    if (!gs || !gs.per_node) return;
    const trimmed = {};
    for (const id in gs.per_node) {
      const m = gs.per_node[id] || {};
      trimmed[id] = {
        mastery_estimate: m.mastery_estimate,
        attempts: m.attempts,
        correct: m.correct,
        min_concept_mastery: m.min_concept_mastery,
        weakest_concept_tag: m.weakest_concept_tag,
      };
    }
    try { localStorage.setItem(_BT_GRAPHSTATE_KEY, JSON.stringify({ per_node: trimmed })); }
    catch (_e) { /* quota — non-fatal */ }
  }

  // Route-PAINT cache — node_id -> [route positions], PAINT ONLY. Drives first-frame
  // badge numbers on repeat opens. Deliberately separate from btRouteMetricsCache (the
  // v89 scalars-only LOGIC cache): this map is read ONLY by the routePositions useMemo
  // (badge rendering); it is NEVER merged into routeMetrics, so cinematic targeting, the
  // decay pulse, and the start-session default still read fresh active_route only. A
  // stale badge is thus a harmless transient placeholder — it can never mis-target.
  const _BT_ROUTEPAINT_KEY = "btRoutePaintCache:v1";
  function _btLoadRoutePaint() {
    try {
      const raw = localStorage.getItem(_BT_ROUTEPAINT_KEY);
      const obj = raw ? JSON.parse(raw) : null;
      return obj && typeof obj === "object" ? obj : null;
    } catch (_e) { return null; }
  }
  function _btSaveRoutePaint(activeRoute) {
    if (!activeRoute || !activeRoute.length) return;
    const map = {};
    activeRoute.forEach((r) => {
      if (!r || r.node_id == null) return;
      (map[r.node_id] = map[r.node_id] || []).push(r.position);
    });
    try { localStorage.setItem(_BT_ROUTEPAINT_KEY, JSON.stringify(map)); }
    catch (_e) { /* quota — non-fatal */ }
  }

  // Route-STEP cache — the first route step's DISPLAY fields + node_id, so the
  // "Start your study adventure" CTA renders instantly on repeat opens instead of
  // waiting for the slow /route/current planner (it's the last thing to paint today).
  // DISPLAY + one user-initiated click fallback only: studyNextOnRoute prefers the
  // FRESH node_id and uses this cached one only if the user clicks before the fresh
  // route lands (studies a ≤1-session-old top node — benign, user-initiated). Never
  // feeds cinematic/decay/auto targeting (v89 logic-safety). Versioned + shape-guarded.
  const _BT_ROUTESTEP_KEY = "btRouteStepCache:v1";
  function _btLoadRouteStep() {
    try {
      const raw = localStorage.getItem(_BT_ROUTESTEP_KEY);
      const obj = raw ? JSON.parse(raw) : null;
      return obj && obj.node_id != null ? obj : null;
    } catch (_e) { return null; }
  }
  function _btSaveRouteStep(step) {
    if (!step || step.node_id == null) return;
    try { localStorage.setItem(_BT_ROUTESTEP_KEY, JSON.stringify({
      node_id: step.node_id,
      title: step.title,
      concept_title: step.concept_title,
      concept_tag: step.concept_tag,
      confidence_band: step.confidence_band,
      rationale: step.rationale,
    })); } catch (_e) { /* quota — non-fatal */ }
  }

  // Loading skeleton for the brief first-open window before /graph/structure lands
  // (repeat opens hit the structure cache and skip this). Fills the would-be-blank
  // center so the gap reads as "working," not broken.
  function BrainTraceLoadingSkeleton() {
    return (
      <div style={{ position: "absolute", inset: 0, zIndex: 1, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none" }}>
        <div style={{ font: '400 15px "DM Sans", system-ui, sans-serif', color: "var(--muted)", letterSpacing: "0.01em" }}>
          Building your knowledge map…
        </div>
      </div>
    );
  }

  function GraphView({
    structure, graphState, routeMetrics, cachedGraphState, cachedRoutePaint,
    graphStateSettled, routeSettled, activeSection, tutorialStep,
    clusterCycleSection, construction, linkingFromNodeId, decayedNodeIds,
    tierCrossingsRef, pulseTick,
    onNodeClick, onNodePrefetch, onNodeRightClick, onLinkRightClick, disabled,
  }) {
    const fgRef = useRef();
    const shellRef = useRef();
    const [dims, setDims] = useState({ w: window.innerWidth, h: window.innerHeight });
    const [hoveredId, setHoveredId] = useState(null);

    // Lazy-acquire the force-graph constructor so the ~150KB lib (react-force-graph-2d
    // + d3-force) loads only when the graph actually mounts. Standalone: the <script>
    // in <head> means window.ForceGraph2D is already set → the initializer returns it
    // synchronously (no fetch). PWA host: the tag is omitted, so we inject it once here
    // and re-render when it resolves. The injection is idempotent — it reuses an in-flight
    // tag (data-force-graph-2d) if another mount already started the load. The functional
    // setState form is REQUIRED because ForceGraph2D is itself a React component (a
    // function); a bare setForceGraph2D(ctor) would invoke it as a state updater.
    const [ForceGraph2D, setForceGraph2D] = useState(() => window.ForceGraph2D || null);
    useEffect(() => {
      if (ForceGraph2D) return;
      if (window.ForceGraph2D) { setForceGraph2D(() => window.ForceGraph2D); return; }
      let cancelled = false;
      const onReady = () => {
        if (!cancelled && window.ForceGraph2D) setForceGraph2D(() => window.ForceGraph2D);
      };
      let s = document.querySelector("script[data-force-graph-2d]");
      if (!s) {
        s = document.createElement("script");
        s.src = "https://unpkg.com/react-force-graph-2d@1.27.1/dist/react-force-graph-2d.min.js";
        s.crossOrigin = "anonymous";
        s.setAttribute("data-force-graph-2d", "1");
        document.head.appendChild(s);
      }
      s.addEventListener("load", onReady);
      // Covers the race where the tag finished loading between querySelector and listen.
      if (window.ForceGraph2D) onReady();
      return () => { cancelled = true; s.removeEventListener("load", onReady); };
    }, [ForceGraph2D]);

    // ── Perf: settled-layout cache + reveal gate ──────────────────────────────
    // topoSig keys the localStorage layout cache; cachedLayout (if present) is
    // injected as node x/y so the graph mounts already-settled. layoutReady gates
    // the canvas reveal + the initial fit so the pre-settle/pre-fit frame is never
    // shown. didCenterFitRef / didDrilldownRef gate the two-step open framing (below).
    const topoSig = useMemo(() => _btTopoSig(structure), [structure]);
    const cachedLayout = useMemo(() => _btLoadLayout(topoSig), [topoSig]);
    const [layoutReady, setLayoutReady] = useState(false);
    // Two-step open framing: Step 1 a centered full-map fit (route-INDEPENDENT), Step 2
    // a smooth zoom to route Node 1 once the (slow) route lands. Separate latches so
    // Step 1 isn't burned before the route arrives.
    const didCenterFitRef = useRef(false);
    const didDrilldownRef = useRef(false);
    // v107: the cinematic drilldown is an OPEN framing shot — it must never
    // fire minutes into a session (see the mount-window gate below).
    const mountedAtRef = useRef(Date.now());
    // zoomToFit is a no-op until react-force-graph has ingested node positions into its
    // internal bbox (a ~1ms-debounced digest). On a cache hit layoutReady flips on the
    // SAME tick positions are injected, so a duration-0 fit fired immediately saw an
    // empty bbox and left the camera at the default tiny zoom (the off-screen sprawl).
    // rAF-poll until ≥16 nodes report x/y, then run the fit (caps at ~30 frames).
    const waitForPositions = (cb, tries = 30) => {
      const fg = fgRef.current;
      if (!fg) return;
      // Readiness = react-force-graph has a non-empty bbox (exactly what zoomToFit
      // consumes internally). getGraphBbox() IS a ref method; graphData() is NOT
      // (calling it threw and crashed the tab — v89 regression). Fall back to the
      // mutated node coords, then fire anyway once the rAF budget is spent.
      let ready = false;
      try {
        if (typeof fg.getGraphBbox === "function") {
          const b = fg.getGraphBbox();
          ready = !!(b && b.x && isFinite(b.x[0]) && isFinite(b.x[1]));
        } else {
          ready = (data.nodes || []).filter(n => n.x != null && n.y != null).length >= 16;
        }
      } catch (_e) { ready = false; }
      if (ready || tries <= 0) cb();
      else requestAnimationFrame(() => waitForPositions(cb, tries - 1));
    };
    // Reveal the canvas: cache hit → immediately (positions injected at mount, no
    // sim, so onEngineStop may never fire for a static graph); cache miss →
    // onEngineStop fires after the invisible warmup settle, with a 2s fallback so a
    // degenerate graph can't leave the canvas hidden.
    useEffect(() => {
      if (layoutReady) return;
      if (cachedLayout) { setLayoutReady(true); return; }
      const t = setTimeout(() => setLayoutReady(true), 2000);
      return () => clearTimeout(t);
    }, [layoutReady, cachedLayout]);

    // ── Coalesced reveal ──────────────────────────────────────────────────────
    // Don't reveal a half-painted graph (the gray → color → number "stepwise" the
    // user reported). Reveal once we can show a COMPLETE graph: layout settled AND
    // colors available (fresh /graph/state OR cached OR the fetch errored) AND badges
    // available (fresh /route/current OR cached OR errored). On a REPEAT open the paint
    // caches make colors+badges available immediately → this reduces to layoutReady
    // (instant, fully-painted). On a COLD open it waits for the fetches so the first
    // reveal is complete — but an absolute ~2.5s deadline force-reveals regardless, so
    // a slow/failed /route/current (the exact call v88 decoupled) can NEVER hang the
    // canvas at opacity 0. The *Settled flags (set in the .catch handlers) let an error
    // unblock the reveal before the deadline. ONE boolean drives the overlay, the canvas
    // opacity, AND the back-to-map pill so they can never diverge.
    const [revealDeadlineHit, setRevealDeadlineHit] = useState(false);
    useEffect(() => {
      const t = setTimeout(() => setRevealDeadlineHit(true), 2500);
      return () => clearTimeout(t);
    }, []);
    const haveColors = !!graphState || !!cachedGraphState || graphStateSettled;
    const haveBadges = !!routeMetrics || !!cachedRoutePaint || routeSettled;
    const revealReady = revealDeadlineHit || (layoutReady && haveColors && haveBadges);

    // Construction-layer lookups (memoized; cheap).
    const annotationIds = useMemo(
      () => new Set(Object.keys(construction?.annotations || {})),
      [construction]
    );
    const attentionIds = useMemo(
      () => new Set(Object.keys(construction?.attention || {})),
      [construction]
    );
    // Day 5: decayed-node pulse set
    const decayedIdSet = useMemo(
      () => new Set(decayedNodeIds || []),
      [decayedNodeIds]
    );

    useEffect(() => {
      // Size the canvas from the graph-canvas CONTAINER, not the window, so the graph
      // reflows when the PWA host offsets .app-shell by the sidebar width via
      // --bt-left-inset (and when that sidebar collapses/expands). ResizeObserver catches
      // both the container-offset change and plain viewport resizes. Standalone: the
      // container fills the viewport, so this equals the old window.innerWidth/Height.
      const measure = () => {
        const el = shellRef.current;
        const w = (el && el.clientWidth) || window.innerWidth;
        const h = (el && el.clientHeight) || window.innerHeight;
        setDims(prev => (prev.w === w && prev.h === h) ? prev : { w, h });
      };
      measure();
      let ro = null;
      if (shellRef.current && typeof ResizeObserver !== "undefined") {
        ro = new ResizeObserver(measure);
        ro.observe(shellRef.current);
      }
      window.addEventListener("resize", measure);
      return () => { if (ro) ro.disconnect(); window.removeEventListener("resize", measure); };
    }, []);

    // Day 4 Slice D2: route position lookup — node_id → SORTED ARRAY of 1-indexed
    // positions. The route is concept-keyed (post-Fix-D substrate): several distinct
    // concepts can share one primary parent node, so a single node_id may carry
    // multiple route positions (e.g. 1 AND 4). Keying a Map by node_id with a scalar
    // value silently dropped all but the last (the missing-route-number bug). Keep
    // every position so the badge renderer can show them all. Used by the badge
    // renderer and the highlighted-edge color callback (which only needs presence).
    const routePositions = useMemo(() => {
      const map = new Map();
      const fresh = routeMetrics?.active_route;
      if (fresh && fresh.length) {
        // Fresh route wins the instant it lands (this useMemo depends on routeMetrics).
        fresh.forEach((r) => {
          const arr = map.get(r.node_id) || [];
          arr.push(r.position);
          map.set(r.node_id, arr);
        });
      } else if (cachedRoutePaint) {
        // First-frame placeholder ONLY (repeat opens): paint last-known badge numbers
        // so the graph reveals already-numbered. Pure paint — no logic site reads this
        // Map (cinematic/decay/start read routeMetrics.active_route directly), so a
        // stale badge can never mis-target. Overridden the instant fresh route lands.
        for (const nodeId in cachedRoutePaint) {
          map.set(nodeId, (cachedRoutePaint[nodeId] || []).slice());
        }
      }
      for (const arr of map.values()) arr.sort((a, b) => a - b);
      return map;
    }, [routeMetrics, cachedRoutePaint]);

    // v107: ordered node ids for the animated route path (position 1 → k).
    // Fresh route only — the cached paint map loses ordering across nodes, and
    // a stale path pointing at last session's nodes would be actively wrong.
    // Consecutive duplicates collapse (a node holding positions 3 AND 4 draws
    // no zero-length segment).
    const routePathIds = useMemo(() => {
      const route = (routeMetrics?.active_route || [])
        .slice()
        .sort((a, b) => (a.position || 0) - (b.position || 0));
      const ids = [];
      route.forEach(r => {
        if (r.node_id && ids[ids.length - 1] !== r.node_id) ids.push(r.node_id);
      });
      return ids;
    }, [routeMetrics]);

    // Convert structure + state into ForceGraph nodes/links. Personal edges
    // are concatenated as a separate kind of link with `personal: true` so the
    // link callbacks can render them differently (dashed, accent color) and
    // also exempt them from the d3-force layout pull (they should not deform
    // the canonical map's settled topology).
    const data = useMemo(() => {
      // Colors come from fresh /graph/state; on a repeat open the cached per-node mastery
      // paints them on frame 1 (SWR — fresh setGraphState overrides). Same stale-while-
      // revalidate contract as the structure cache: if /graph/state errors we keep the
      // cached tiers rather than flashing to gray (a <1-session-old tier beats a flash).
      const perNode = graphState?.per_node || cachedGraphState?.per_node || {};
      const nodes = (structure.nodes || []).map(n => {
        const m = perNode[n.id] || {};
        const pos = cachedLayout && cachedLayout[n.id];
        return {
          // Pre-position from the settled-layout cache so the graph renders
          // already-laid-out (no spring). Absent on a cache miss → d3-force lays
          // it out during the invisible warmup, then onEngineStop caches it.
          ...(pos ? { x: pos[0], y: pos[1] } : {}),
          id: n.id,
          name: n.title,
          section: n.psite_section,
          difficulty: n.difficulty,
          tested_frequency: n.tested_frequency,
          attempts: m.attempts || 0,
          correct: m.correct || 0,
          mastery_estimate: m.mastery_estimate ?? null,
          // Fix D — render rule β: dual-band signal.
          //   fill = mastery_estimate (already wired downstream)
          //   halo intensity = 1 - min_concept_mastery (surfaces hidden weak
          //   concepts inside otherwise-competent topics).
          // weakest_concept_tag is shown on hover so the user knows which
          // specific concept is dragging the halo.
          min_concept_mastery: m.min_concept_mastery ?? null,
          weakest_concept_tag: m.weakest_concept_tag ?? null,
        };
      });
      const links = (structure.edges || []).map(e => ({
        source: e.source,
        target: e.target,
        personal: false,
      }));
      const personalLinks = (construction?.personal_edges || []).map(e => ({
        source: e.source_id,
        target: e.target_id,
        personal: true,
        edge_id: e.id,
        label: e.label,
      }));
      return { nodes, links: [...links, ...personalLinks] };
    }, [structure, graphState, cachedGraphState, construction, cachedLayout]);

    // "Fit the bulk" filter for zoomToFit. The library's built-in zoomToFit
    // fits ALL nodes; with a long tail of moderately-distant nodes (and the
    // occasional extreme floater like Breast Cancer Lymphedema), the bounding
    // box gets stretched and the visual mass appears off-center.
    //
    // Approach: rank nodes by distance from the median centroid (median is
    // robust to outliers — a single floater doesn't drag it). Keep the
    // closest BULK_FIT_KEEP fraction; exclude the rest. 0.90 ≈ "trim ~14 of
    // 140 nodes" — removes only the genuinely-far outliers (the lone
    // Breast Cancer Lymphedema floater + a handful of extremes) while
    // preserving the legitimate periphery (upper-cluster nodes 6/8 etc.)
    // so the bounding box reflects the true visual mass of the network.
    //
    // Tunable: lower = more aggressive (tighter focus, more cropped); higher
    // = looser (closer to fitting all). 0.78 cropped too much of the upper
    // periphery and biased the visible mass right + down; 0.90 includes the
    // upper structure while still excluding the bottom-left floater.
    //
    // zoomToFit ignores undefined/null filters, so this degrades gracefully
    // when positions aren't ready yet.
    const BULK_FIT_KEEP = 0.90;
    const bulkFitFilter = useCallback(() => {
      const positioned = data.nodes.filter(n => n.x != null && n.y != null);
      if (positioned.length < 16) return undefined;
      const xs = positioned.map(n => n.x).sort((a, b) => a - b);
      const ys = positioned.map(n => n.y).sort((a, b) => a - b);
      const median = (arr) => arr[Math.floor(arr.length / 2)];
      const cx = median(xs);
      const cy = median(ys);
      const dists = positioned.map(n => {
        const dx = n.x - cx, dy = n.y - cy;
        return Math.sqrt(dx * dx + dy * dy);
      }).sort((a, b) => a - b);
      const cutoffIdx = Math.max(0, Math.min(dists.length - 1,
        Math.floor(dists.length * BULK_FIT_KEEP)));
      const cutoff = dists[cutoffIdx];
      return (node) => {
        if (node.x == null || node.y == null) return true;
        const dx = node.x - cx, dy = node.y - cy;
        return Math.sqrt(dx * dx + dy * dy) <= cutoff;
      };
    }, [data.nodes]);

    // v107: animated directional route path — "your path forward". A dashed
    // straight-segment path through route positions 1 → k, with the dash offset driven
    // by performance.now() so the dashes march along the travel direction
    // (the ~30fps living-graph refresh below keeps it animating). Drawn in
    // onRenderFramePre so it sits UNDER nodes, badges, and halos. Render-only:
    // no synthetic links, so the force layout is untouched.
    const nodeById = useMemo(() => {
      const m = new Map();
      (data.nodes || []).forEach(n => m.set(n.id, n));
      return m;
    }, [data.nodes]);
    const drawRoutePath = useCallback((ctx, globalScale) => {
      if (routePathIds.length < 2) return;
      const pts = [];
      routePathIds.forEach(id => {
        const n = nodeById.get(id);
        if (n && n.x != null && n.y != null) pts.push(n);
      });
      if (pts.length < 2) return;
      const gs = globalScale || 1;
      const dash = 7 / gs, gap = 5 / gs;
      ctx.save();
      ctx.strokeStyle = "rgba(45, 92, 79, 0.45)";   /* deep green, translucent */
      ctx.lineWidth = Math.max(0.6, 2.4 / gs);
      ctx.lineCap = "round";
      ctx.setLineDash([dash, gap]);
      // Negative offset drift → dashes travel from position 1 toward position k.
      ctx.lineDashOffset = -((performance.now() / 1000) * 18 / gs) % (dash + gap);
      // v110: straight connect-the-dots through positions 1 → k, in sequence.
      // The prior quadratic-curve smoothing swooped wildly between spatially
      // scattered beads (route order != map position), reading as tangled
      // spaghetti. Direct segments make the ordered path legible — the beads
      // are numbered, so the eye follows 1 → 2 → ... → k straight through.
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
      ctx.stroke();
      ctx.restore();
    }, [routePathIds, nodeById]);

    // Living-graph: continuous animation wake-up.
    //
    // The ambient per-node breath in nodeColor and the route halos in
    // nodeCanvasObject both rely on performance.now() being polled every frame.
    // react-force-graph-2d's internal RAF loop runs only when SOMETHING is
    // animating (route particles, decayed-node pulse, camera tween, drag, etc.).
    // In edge cases — cold-start before any route exists, no decayed nodes —
    // the loop pauses and the breath would visibly freeze.
    //
    // This RAF loop calls fgRef.current.refresh() at ~30fps, forcing a canvas
    // redraw every frame so the breath never stops. When the lib's internal
    // loop is already running, refresh() is a near-no-op (one extra paint per
    // frame at worst). When the lib is at rest, this single call is what
    // keeps the graph visibly alive.
    //
    // Skipped under prefers-reduced-motion; OS-level accessibility wins.
    useEffect(() => {
      const reduce = typeof window !== "undefined"
        && window.matchMedia
        && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      if (reduce) return;
      let rafId = 0;
      let lastT = 0;
      const tick = (t) => {
        // Throttle to ~30fps. Breath is sub-conscious; full 60fps wastes power
        // on a phone or laptop without making the animation read smoother.
        if (t - lastT > 33) {
          if (fgRef.current && typeof fgRef.current.refresh === "function") {
            fgRef.current.refresh();
          }
          lastT = t;
        }
        rafId = requestAnimationFrame(tick);
      };
      rafId = requestAnimationFrame(tick);
      return () => { if (rafId) cancelAnimationFrame(rafId); };
    }, []);

    // Mobile route-first camera state — see the route-fit effect below STEP 2.
    const isMobileView = useViewportIsMobile();
    const didMobileRouteFitRef = useRef(false);

    // STEP 1 — centered full-map fit (the "Back to map" / Image-3 view). Route-
    // INDEPENDENT, so it fires as soon as the layout is ready; waitForPositions guards
    // against the zoomToFit-before-bbox-ingested no-op that left the map sprawling
    // off-screen. Real 600ms glide so it reads as a deliberate establishing shot.
    useEffect(() => {
      if (!layoutReady || !fgRef.current || !data.nodes.length || didCenterFitRef.current) return;
      didCenterFitRef.current = true;
      waitForPositions(() => { if (fgRef.current) fgRef.current.zoomToFit(600, 110, bulkFitFilter()); });
    }, [layoutReady, data.nodes.length, bulkFitFilter]);

    // STEP 2 — once-per-session smooth drilldown to route Node 1 (+ positions 2-3 and
    // their 1-hop neighbors = the discriminative-contrast neighborhood). DEPENDS on
    // routeMetrics, so it correctly WAITS for the slow /route/current instead of burning
    // a shared latch while the route is still null (the bug that suppressed the zoom).
    // Self-scaling bounding-box zoomToFit (not an absolute zoom) → never over-crops.
    useEffect(() => {
      if (!layoutReady || !fgRef.current || !data.nodes.length || didDrilldownRef.current) return;
      // Mobile uses the always-on route-fit below instead of this once-per-
      // session drilldown — running both would race two camera animations.
      if (isMobileView) { didDrilldownRef.current = true; return; }
      const route = routeMetrics?.active_route || [];
      if (!route.length) return;
      if (sessionStorage.getItem("brainTrace.cinematicOpenFiredAt") !== null) { didDrilldownRef.current = true; return; }
      // v107 mount-window gate: when /route/current is slow, routeMetrics can
      // land LONG after open — including via closeStudy's refresh after the
      // user finishes questions. Firing then read as an uninvited hyper-zoom
      // right after the full-map fit (the exact v105 bug report). An open
      // framing shot only makes sense at open: skip after the first seconds
      // of this mount, and never while a study surface covers the map.
      if (disabled || Date.now() - mountedAtRef.current > 8000) {
        didDrilldownRef.current = true;
        return;
      }
      didDrilldownRef.current = true;
      // Latch the once-per-session guard at SCHEDULING time (not fire time) so
      // nothing can re-arm the drilldown while the 900ms timer is pending.
      sessionStorage.setItem("brainTrace.cinematicOpenFiredAt", String(Date.now()));
      const seedIds = route.slice(0, 3).map(r => r.node_id).filter(Boolean);
      const cineT = setTimeout(() => {
        if (!fgRef.current || !seedIds.length) return;
        const seed = new Set(seedIds);
        const focusSet = new Set(seed);
        (data.links || []).forEach(l => {
          const s = typeof l.source === "object" ? l.source.id : l.source;
          const t = typeof l.target === "object" ? l.target.id : l.target;
          if (seed.has(s)) focusSet.add(t);
          if (seed.has(t)) focusSet.add(s);
        });
        fgRef.current.zoomToFit(1000, 140, (n) => focusSet.has(n.id) && n.x != null && n.y != null);
      }, 900);  /* breathing room after the centered fit, so the user sees the whole map first */
      return () => clearTimeout(cineT);
    }, [layoutReady, data.nodes.length, routeMetrics, bulkFitFilter, disabled]);

    // MOBILE — route-first framing. A full-map fit renders 635 nodes as an
    // unreadable smudge in a phone viewport; the resting view on mobile is the
    // user's active route (all positions, self-scaling bbox), so the numbered
    // beads are large and tappable. Fires once per mount, EVERY open (no
    // sessionStorage gate — this is the default view, not a cinematic), ~700ms
    // after the STEP 1 establishing fit so the glide reads full map → my route.
    // "Default Map View" still offers the global fit; pinch/pan free after.
    useEffect(() => {
      if (!isMobileView || !layoutReady || !fgRef.current || !data.nodes.length) return;
      if (didMobileRouteFitRef.current || disabled) return;
      const route = routeMetrics?.active_route || [];
      const ids = new Set(route.map(r => r.node_id).filter(Boolean));
      if (!ids.size) return;
      didMobileRouteFitRef.current = true;
      const fitT = setTimeout(() => {
        waitForPositions(() => {
          if (!fgRef.current) return;
          // Centroid + spread-clamped zoom (same pattern as the tour cluster
          // camera) instead of zoomToFit: route nodes are often tightly
          // clustered in world space, and fitting their tiny bbox over-zooms
          // into a blob of overlapping beads. The cap keeps the neighborhood
          // context visible; the floor keeps a sprawling route from reading
          // as the full-map smudge this effect exists to avoid.
          const routeNodes = data.nodes.filter(n => ids.has(n.id) && n.x != null && n.y != null);
          if (!routeNodes.length) return;
          const cx = routeNodes.reduce((s, n) => s + n.x, 0) / routeNodes.length;
          const cy = routeNodes.reduce((s, n) => s + n.y, 0) / routeNodes.length;
          const xs = routeNodes.map(n => n.x);
          const ys = routeNodes.map(n => n.y);
          const spread = Math.max(
            Math.max(...xs) - Math.min(...xs),
            Math.max(...ys) - Math.min(...ys),
            1
          );
          const targetZoom = Math.min(2.2, Math.max(0.5, 300 / spread));
          fgRef.current.centerAt(cx, cy, 900);
          fgRef.current.zoom(targetZoom, 900);
        });
      }, 700);
      return () => clearTimeout(fitT);
    }, [isMobileView, layoutReady, data.nodes.length, routeMetrics, disabled]);

    // "Back to map" handler — re-fit to the bulk-centered global view
    const backToMap = useCallback(() => {
      if (fgRef.current) fgRef.current.zoomToFit(800, 110, bulkFitFilter());
    }, [bulkFitFilter]);

    // Slice D-polish v4: tour cluster camera. When clusterCycleSection changes
    // during the tutorial map card, zoom the camera onto the centroid of that
    // section's nodes so the user can SEE the cluster, not just the dimming.
    // When clusterCycleSection becomes null (cycle wraps to "all"), zoom out.
    useEffect(() => {
      if (!fgRef.current || !data.nodes.length) return;
      // v107: camera moves ONLY while the tour's map card is actually running.
      // This effect's deps include data.nodes, whose identity changes on every
      // /graph/state refresh (closeStudy, answer-driven repaints, construction
      // edits) — pre-fix, each refresh re-fired the null branch's zoomToFit
      // OUTSIDE the tour, snapping the camera away from wherever the user had
      // panned (the "map jumps / locks up until Back to map" reports).
      if (tutorialStep !== 2) return;
      if (clusterCycleSection === null) {
        // Wrap-around pause — re-fit the bulk-centered global view
        fgRef.current.zoomToFit(700, 120, bulkFitFilter());
        return;
      }
      const matching = data.nodes.filter(
        (n) => n.section === clusterCycleSection && n.x != null && n.y != null
      );
      if (!matching.length) return;
      const cx = matching.reduce((s, n) => s + n.x, 0) / matching.length;
      const cy = matching.reduce((s, n) => s + n.y, 0) / matching.length;
      // Choose a zoom level proportional to the cluster's spatial spread —
      // tighter clusters get a tighter zoom, sprawling sections stay readable.
      const xs = matching.map((n) => n.x);
      const ys = matching.map((n) => n.y);
      const spread = Math.max(
        Math.max(...xs) - Math.min(...xs),
        Math.max(...ys) - Math.min(...ys),
        1
      );
      const targetZoom = Math.min(2.6, Math.max(1.2, 600 / spread));
      fgRef.current.centerAt(cx, cy, 700);
      fgRef.current.zoom(targetZoom, 700);
    }, [clusterCycleSection, data.nodes, bulkFitFilter]);

    if (!ForceGraph2D) {
      // Transient on first PWA mount (lib loading) or terminal (CDN blocked).
      return (
        <div style={{ padding: 32, color: "var(--muted)", font: '400 15px "DM Sans", system-ui, sans-serif' }}>
          Loading graph engine…
        </div>
      );
    }

    // Helper: extract id from link.source/target which may be a string or object
    const linkEndpointId = (e) => (typeof e === "object" ? e.id : e);
    const isRouteEdge = (link) => {
      const sId = linkEndpointId(link.source);
      const tId = linkEndpointId(link.target);
      return routePositions.has(sId) && routePositions.has(tId);
    };

    // Slice D-polish: section dimming. Two independent sources can dim:
    //   (1) user-set activeSection (View by Section pill)
    //   (2) tutorial Card 2 cluster-cycle (auto-pulses each section in turn)
    // Either dims non-matching nodes to ~25% opacity to preserve graph context.
    // The cycle takes precedence visually but both can be set; matched section
    // wins if it equals the test condition.
    const effectiveSection = clusterCycleSection || activeSection;
    const dimNode = (node) => effectiveSection !== null && node.section !== effectiveSection;

    // ── Living-graph: per-node alpha breath ──────────────────────────────────
    // Each node has a deterministic phase offset (derived from a hash of its
    // node_id) so the 140 nodes don't pulse in sync — without random phases the
    // graph would read as a single mechanical heartbeat. With per-node phase,
    // the breath looks organic, like cells breathing independently.
    //
    // Period: 4500ms. Amplitude: ±12% alpha. Visible enough that the graph
    // clearly feels alive without overwhelming the louder signal of the route
    // halo (in nodeCanvasObject) and the decayed-node pulse, which drive their
    // own faster, brighter cycles on top.
    //
    // The phase is cached on the node object after first computation so we
    // don't re-hash on every render frame.
    const breathPhaseFor = (node) => {
      if (node._breathPhase != null) return node._breathPhase;
      let h = 5381;
      const id = node.id || "";
      for (let i = 0; i < id.length; i++) h = ((h << 5) + h + id.charCodeAt(i)) | 0;
      node._breathPhase = (Math.abs(h) % 1000) / 1000;
      return node._breathPhase;
    };
    const breathMul = (node) => {
      const phase = breathPhaseFor(node);
      const t = (performance.now() / 4500) + phase;
      return 1 + Math.sin(t * 2 * Math.PI) * 0.12;
    };
    // Apply the breath multiplier to a hex color (with or without alpha suffix)
    // and return rgba(). Centralized so the math stays consistent across the
    // dimmed and undimmed branches below.
    const breatheColor = (hex, mul) => {
      const h = hex.replace("#", "");
      const r = parseInt(h.slice(0, 2), 16);
      const g = parseInt(h.slice(2, 4), 16);
      const b = parseInt(h.slice(4, 6), 16);
      const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
      const newA = Math.max(0, Math.min(1, a * mul));
      return `rgba(${r},${g},${b},${newA.toFixed(3)})`;
    };

    const nodeColor = (node) => {
      const c = masteryColor(node);
      const mul = breathMul(node);
      // Dimmed nodes: bake the dim factor (~25%) into the breath multiplier
      // so we still go through breatheColor and get a consistent rgba output.
      const finalMul = dimNode(node) ? mul * 0.25 : mul;
      return breatheColor(c, finalMul);
    };

    // Slice D-polish: futurism layer. Route nodes get a halo whose opacity
    // breathes on a ~5s sine cycle (amplitude ±0.18 around 0.55). Hover
    // strengthens the halo to a steady 0.9 with a wider radius. The
    // continuously-animated linkDirectionalParticles below force frame
    // repaints, so performance.now() in here drives smooth animation
    // without our own RAF loop.
    const nodeCanvasOverlay = (node, ctx, globalScale) => {
      const baseR0 = nodeRadius(node);
      const dimmed = dimNode(node);

      // ── Living-graph ambient halo — visible on EVERY node (not just route).
      // A faint stroked ring at radius × 1.5 whose alpha breathes on the same
      // per-node phase as the alpha breath in nodeColor (so a node's color
      // pulse and halo pulse are synchronized). The halo is the louder visual
      // signal — nodes appear to literally "breathe" outward and contract.
      // Skipped on dimmed nodes (active section filter would compete) and on
      // route nodes (their stronger accent halo replaces this one).
      if (!dimmed && !routePositions.has(node.id) && !decayedIdSet.has(node.id) && linkingFromNodeId !== node.id) {
        const phase = breathPhaseFor(node);
        const tBreath = (performance.now() / 4500) + phase;
        const breathAlpha = Math.max(0, Math.sin(tBreath * 2 * Math.PI) * 0.10 + 0.14);
        const breathR = baseR0 * (1.4 + Math.sin(tBreath * 2 * Math.PI) * 0.12);
        if (breathAlpha > 0.02) {  // skip drawing during the trough — saves paint cost
          ctx.beginPath();
          ctx.arc(node.x, node.y, breathR, 0, 2 * Math.PI);
          // Color matches the node's mastery tier so weak nodes pulse warm-clay,
          // mastered pulse deep-green, etc. — ties the breath to the data, not
          // an arbitrary accent.
          const tierHex = masteryColor(node).replace("#", "");
          const r = parseInt(tierHex.slice(0, 2), 16);
          const g = parseInt(tierHex.slice(2, 4), 16);
          const b = parseInt(tierHex.slice(4, 6), 16);
          ctx.strokeStyle = `rgba(${r},${g},${b},${breathAlpha.toFixed(3)})`;
          ctx.lineWidth = 1.0 / globalScale;
          ctx.stroke();
        }
      }

      // Fix D — render rule β (dual-band hidden-weakness halo). Independent of
      // the ambient breath above: this halo's intensity encodes (1 - min concept
      // mastery), surfacing hidden weak concepts inside otherwise-competent
      // nodes. Color is warm clay (the weak-tier mastery color) so it reads as
      // "weakness signal" against the green-tier fills.
      //
      // Suppressed on:
      //   - dimmed nodes (active section filter)
      //   - route nodes (their accent halo already commands attention; stacking
      //     two halos at similar radii would clutter)
      //   - decayed nodes (their amber pulse uses a similar hue)
      //   - linking-source node (its dashed pulse owns the visual channel)
      //   - never-attempted nodes (min_concept_mastery is null — no data)
      //   - nodes whose min already > 0.85 (intensity ≤ 0.15 → effectively invisible)
      const minCm = node.min_concept_mastery;
      const haloVisible = minCm !== null && minCm !== undefined
        && minCm < 0.85
        && !dimmed
        && !routePositions.has(node.id)
        && !decayedIdSet.has(node.id)
        && linkingFromNodeId !== node.id;
      if (haloVisible) {
        const intensity = Math.max(0, 1.0 - minCm);
        // Cap alpha at 0.45 so the hidden-weakness halo never dominates the
        // route halo (which peaks at 0.9). Linear scaling on intensity keeps
        // the perceptual ramp predictable: a node at min=0.20 reads
        // ~3× brighter than a node at min=0.65.
        const alpha = Math.min(0.45, intensity * 0.55);
        if (alpha > 0.04) {
          const haloR = baseR0 * 1.7;
          ctx.beginPath();
          ctx.arc(node.x, node.y, haloR, 0, 2 * Math.PI);
          ctx.strokeStyle = `rgba(181, 99, 76, ${alpha.toFixed(3)})`;  /* warm clay (weak-tier) */
          ctx.lineWidth = (1.0 + intensity * 1.4) / globalScale;
          ctx.stroke();
        }
      }

      // Stage 3c: tier-transition pulse. One-shot expanding ring that fires
      // for ~800ms when a node's mastery just crossed into a higher tier
      // (or graduated via the spaced fast-path). Surfaces the climb that's
      // already happening underneath — pure visibility, no math change.
      const pulseStart = tierCrossingsRef && tierCrossingsRef.current
        ? tierCrossingsRef.current.get(node.id) : null;
      if (pulseStart != null) {
        const elapsed = performance.now() - pulseStart;
        if (elapsed >= 0 && elapsed < 800) {
          const t = elapsed / 800;                    // 0 → 1 across the animation
          const ringR = baseR0 * (1.0 + t * 1.6);     // grow from 1.0× to 2.6× radius
          const ringAlpha = (1 - t) * 0.7;            // fade 0.7 → 0
          ctx.beginPath();
          ctx.arc(node.x, node.y, ringR, 0, 2 * Math.PI);
          // Deep editorial green — same accent the mastered tier uses, so
          // "you just went up" reads cohesively with the rest of the palette.
          ctx.strokeStyle = `rgba(45, 92, 79, ${ringAlpha.toFixed(3)})`;
          ctx.lineWidth = (2.0 + t * 1.0) / globalScale;
          ctx.stroke();
        }
      }

      // Construction layer indicators — applied to ALL nodes (route or not), but
      // hidden on dimmed nodes for cleanliness. Drawn first so route adornments
      // (halo + numbered badge) render on top.
      if (!dimmed && attentionIds.has(node.id)) {
        // Warm amber ring at radius × 1.55 — quieter than route halo but visible
        ctx.beginPath();
        ctx.arc(node.x, node.y, baseR0 * 1.55, 0, 2 * Math.PI);
        ctx.strokeStyle = "rgba(181, 137, 76, 0.85)";  /* --warn */
        ctx.lineWidth = 1.4 / globalScale;
        ctx.stroke();
      }
      if (!dimmed && annotationIds.has(node.id)) {
        // Tiny note glyph upper-left — a 6×6 rounded square in the muted color
        const glyphR = 4.5 / globalScale;
        const offset = baseR0 * 0.85;
        const gx = node.x - offset;
        const gy = node.y - offset;
        ctx.fillStyle = "#6B6B67";  /* --muted */
        ctx.beginPath();
        ctx.arc(gx, gy, glyphR, 0, 2 * Math.PI);
        ctx.fill();
        // White interior dot to read as "note" rather than just a dot
        ctx.fillStyle = "#FFFFFF";
        ctx.beginPath();
        ctx.arc(gx, gy, glyphR * 0.4, 0, 2 * Math.PI);
        ctx.fill();
      }
      if (linkingFromNodeId === node.id && !dimmed) {
        // Pulsing accent ring on the linking-source node
        const t = (performance.now() % 1500) / 1500;
        const pulse = Math.sin(t * 2 * Math.PI) * 0.25 + 0.65;
        ctx.beginPath();
        ctx.arc(node.x, node.y, baseR0 * 1.85, 0, 2 * Math.PI);
        ctx.strokeStyle = `rgba(45, 92, 79, ${pulse.toFixed(3)})`;
        ctx.lineWidth = 1.8 / globalScale;
        ctx.setLineDash([3 / globalScale, 3 / globalScale]);
        ctx.stroke();
        ctx.setLineDash([]);
      }
      // Day 5: decayed-node pulse — slow amber breath on nodes whose
      // recall_prob has fallen below the spaced-rep review threshold. Cycles
      // ~3.5s; alpha 0.30–0.65 on radius 1.4× so the route halo (1.65×)
      // still reads on top when a route node is also decayed.
      if (!dimmed && decayedIdSet.has(node.id) && linkingFromNodeId !== node.id) {
        const dt = (performance.now() % 3500) / 3500;
        const dpulse = Math.sin(dt * 2 * Math.PI) * 0.18 + 0.48;
        ctx.beginPath();
        ctx.arc(node.x, node.y, baseR0 * 1.4, 0, 2 * Math.PI);
        ctx.strokeStyle = `rgba(181, 137, 76, ${dpulse.toFixed(3)})`;  /* --warn */
        ctx.lineWidth = 1.2 / globalScale;
        ctx.stroke();
      }

      if (!routePositions.has(node.id)) return;
      if (dimmed) return;  /* hide route adornments on dimmed nodes */
      const positions = routePositions.get(node.id);
      const baseR = baseR0;
      const isHovered = node.id === hoveredId;
      // Slice D-polish v3: amplify position-1's halo when the user is on tutorial
      // Card 8 ("Start your study adventure") — visually anchors the CTA at the
      // graph's recommended starting node.
      const tutorialAmplify = tutorialStep === 6 && positions.includes(1);

      // Breathing alpha — sine wave, ~5s period, amplitude ±0.18 over base 0.55
      // Card-4 amplification widens the breath cycle: alpha 0.4–1.0 at 2.5s period.
      const t = (performance.now() % (tutorialAmplify ? 2500 : 5000)) / (tutorialAmplify ? 2500 : 5000);
      const breath = tutorialAmplify
        ? Math.sin(t * 2 * Math.PI) * 0.30 + 0.70
        : Math.sin(t * 2 * Math.PI) * 0.18 + 0.55;
      const haloAlpha = isHovered ? 0.9 : breath;
      const haloR = baseR * (
        tutorialAmplify ? 2.4 :
        isHovered ? 2.0 : 1.65
      );

      ctx.beginPath();
      ctx.arc(node.x, node.y, haloR, 0, 2 * Math.PI);
      ctx.strokeStyle = `rgba(45, 92, 79, ${haloAlpha.toFixed(3)})`;
      ctx.lineWidth = ((
        tutorialAmplify ? 2.6 :
        isHovered ? 2.0 : 1.4
      ) * (isMobileView ? 1.4 : 1)) / globalScale;
      ctx.stroke();

      // Numbered badge — upper-right pill, white digit(s). A node may own multiple
      // route positions (concept-keyed route), so render ALL of them ("1·4") in a
      // single stadium pill rather than dropping any. For one position the pill is a
      // circle, identical to the original look. Screen-constant size; scaled up
      // ~1.5× on mobile where the desktop 13px pill reads as a speck.
      const bScale = isMobileView ? 1.5 : 1;
      const label = positions.join("·");
      const offset = baseR * 0.85;
      const bx = node.x + offset;
      const by = node.y - offset;
      ctx.font = `bold ${(8 * bScale) / globalScale}px 'DM Mono', monospace`;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      const h = (13 * bScale) / globalScale;
      const r = h / 2;
      const padX = (4 * bScale) / globalScale;
      const w = Math.max(h, ctx.measureText(label).width + padX * 2);  // circle when single digit
      const lcx = bx - w / 2 + r;   // left cap center
      const rcx = bx + w / 2 - r;   // right cap center
      ctx.fillStyle = "#2D5C4F";  /* --accent */
      ctx.beginPath();
      ctx.arc(lcx, by, r, Math.PI / 2, Math.PI * 1.5);   // left semicircle
      ctx.arc(rcx, by, r, Math.PI * 1.5, Math.PI / 2);   // right semicircle
      ctx.closePath();
      ctx.fill();
      ctx.fillStyle = "#FFFFFF";
      ctx.fillText(label, bx, by);
    };

    const linkColor = (link) => {
      const sId = linkEndpointId(link.source);
      const tId = linkEndpointId(link.target);
      const sNode = typeof link.source === "object" ? link.source : null;
      const tNode = typeof link.target === "object" ? link.target : null;
      const sectionDimmed = effectiveSection !== null && (
        (sNode && sNode.section !== effectiveSection) ||
        (tNode && tNode.section !== effectiveSection)
      );
      if (link.personal) {
        return sectionDimmed ? "rgba(45, 92, 79, 0.25)" : "rgba(45, 92, 79, 0.7)";
      }
      if (routePositions.has(sId) && routePositions.has(tId)) {
        return sectionDimmed ? "rgba(45, 92, 79, 0.25)" : "rgba(45, 92, 79, 0.85)";
      }
      return sectionDimmed ? "rgba(234, 234, 234, 0.4)" : "#EAEAEA";
    };

    const linkWidth = (link) => link.personal ? 1.2 : (isRouteEdge(link) ? 1.6 : 0.5);

    // Slice D-polish: directional particles drift along route edges, suggesting
    // forward motion. 2 particles per route edge, slow speed, accent color.
    const linkDirectionalParticles = (link) => isRouteEdge(link) ? 2 : 0;

    // Custom canvas draw for personal edges only — dashed line so they read as
    // "user-drawn" against the solid wikilinks. Returning false from this
    // callback for non-personal links lets the default renderer handle them.
    const linkCanvasObject = (link, ctx, globalScale) => {
      if (!link.personal) return;
      const s = typeof link.source === "object" ? link.source : null;
      const t = typeof link.target === "object" ? link.target : null;
      if (!s || !t || s.x == null || t.x == null) return;
      const sectionDimmed = effectiveSection !== null && (
        (s.section !== effectiveSection) || (t.section !== effectiveSection)
      );
      ctx.save();
      ctx.beginPath();
      ctx.setLineDash([5 / globalScale, 4 / globalScale]);
      ctx.strokeStyle = sectionDimmed ? "rgba(45, 92, 79, 0.25)" : "rgba(45, 92, 79, 0.7)";
      ctx.lineWidth = 1.2 / globalScale;
      ctx.moveTo(s.x, s.y);
      ctx.lineTo(t.x, t.y);
      ctx.stroke();
      ctx.restore();
    };
    const linkCanvasObjectMode = (link) => link.personal ? "replace" : undefined;

    // Beef up the right-click hit area for personal edges so the tiny dashed line
    // is forgiving to click. react-force-graph-2d uses a separate hidden canvas
    // for hit-testing; we paint a wider stroke here so cursor proximity registers.
    const linkPointerAreaPaint = (link, color, ctx) => {
      if (!link.personal) return;
      const s = typeof link.source === "object" ? link.source : null;
      const t = typeof link.target === "object" ? link.target : null;
      if (!s || !t || s.x == null || t.x == null) return;
      ctx.beginPath();
      ctx.strokeStyle = color;
      ctx.lineWidth = 8;     // generous click target around the dashed line
      ctx.moveTo(s.x, s.y);
      ctx.lineTo(t.x, t.y);
      ctx.stroke();
    };

    return (
      <div ref={shellRef} className="graph-canvas" style={{ pointerEvents: disabled ? "none" : "auto" }}>
        {/* Loading affordance while the layout settles (cold first open). The canvas
            itself is opacity-gated below; this overlay is NOT, so the user sees
            "Laying out your map…" instead of a blank/transparent canvas. */}
        {!revealReady && (
          <div style={{ position: "absolute", inset: 0, zIndex: 2, display: "flex", alignItems: "center", justifyContent: "center", font: '400 15px "DM Sans", system-ui, sans-serif', color: "var(--muted)", pointerEvents: "none" }}>
            Laying out your map…
          </div>
        )}
        {/* Reveal gate (coalesced): hide ONLY the canvas until layout settled AND the
            graph is fully painted (colors + badges from fresh/cache/errored), capped by
            the 2.5s deadline. Keeps .graph-canvas visible so the overlay above can show.
            Single `revealReady` boolean shared with the overlay + back-to-map pill. */}
        <div style={{ width: "100%", height: "100%", opacity: revealReady ? 1 : 0, transition: "opacity 200ms ease" }}>
        <ForceGraph2D
          ref={fgRef}
          graphData={data}
          width={dims.w}
          height={dims.h}
          backgroundColor="#FBFBFA"
          nodeRelSize={1}
          nodeVal={n => Math.pow(nodeRadius(n), 2) / 8}  /* react-force-graph-2d uses area not radius */
          /* Subagent B — enlarge the CLICK target without changing visuals. With no
             nodePointerAreaPaint set, the hit circle == the tiny visual circle
             (~1.4-5px), making small/unattempted nodes hard to click. Paint the hit
             circle with a ~9px screen-space floor (9/globalScale in graph units) so the
             target grows when zoomed out, exactly when nodes look smallest. The visible
             node is the default paint + the "after" overlay — both unchanged. */
          nodePointerAreaPaint={(node, color, ctx, globalScale) => {
            const visR = nodeRadius(node) / Math.sqrt(8);
            /* Mobile: ~14px screen-space floor ≈ a 28px tap circle — the iOS
               minimum touch-target guideline. Desktop keeps the 9px floor. */
            const r = Math.max(visR, (isMobileView ? 14 : 9) / (globalScale || 1));
            ctx.fillStyle = color;
            ctx.beginPath();
            ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
            ctx.fill();
          }}
          nodeColor={nodeColor}
          nodeLabel={n => {
            const positions = routePositions.get(n.id);
            const route = positions ? `route #${positions.join(", #")} · ` : "";
            const mastery = n.attempts
              ? `mastery ${(n.mastery_estimate || 0).toFixed(2)} (${n.attempts} attempts)`
              : "unattempted";
            return `${route}${n.name} · ${n.section} · ${mastery}`;
          }}
          nodeCanvasObject={nodeCanvasOverlay}
          nodeCanvasObjectMode={() => "after"}
          onRenderFramePre={drawRoutePath}
          onNodeHover={(n) => { setHoveredId(n ? n.id : null); if (n && onNodePrefetch) onNodePrefetch(n.id).catch(() => {}); }}
          linkColor={linkColor}
          linkWidth={linkWidth}
          linkCanvasObject={linkCanvasObject}
          linkCanvasObjectMode={linkCanvasObjectMode}
          linkPointerAreaPaint={linkPointerAreaPaint}
          onLinkRightClick={(link, event) => !disabled && onLinkRightClick && onLinkRightClick(link, event)}
          linkDirectionalParticles={linkDirectionalParticles}
          linkDirectionalParticleSpeed={0.0025}
          linkDirectionalParticleWidth={1.6}
          linkDirectionalParticleColor={() => "#2D5C4F"}
          onNodeClick={(node) => !disabled && onNodeClick(node)}
          onNodeRightClick={(node, event) => !disabled && onNodeRightClick && onNodeRightClick(node, event)}
          d3VelocityDecay={0.35}
          /* Pan/zoom navigation. enableNodeDrag is OFF (no onNodeDrag handlers exist;
             the construction layer uses right-click) so a click-drag PANS the map
             instead of grabbing a node that — with cooldownTicks=0 — would never
             re-settle. Pan/zoom interaction explicit for clarity. */
          enableNodeDrag={false}
          enablePanInteraction={true}
          enableZoomInteraction={true}
          /* Perf: render already-settled. Cache hit → 0 warmup + 0 cooldown (paint
             the injected positions, no sim). Cache miss → settle invisibly via
             warmupTicks (before first paint), then 0 cooldown so nothing animates
             on screen; onEngineStop caches the result for next time. */
          warmupTicks={cachedLayout ? 0 : _BT_WARMUP_TICKS}
          cooldownTicks={0}
          onEngineStop={() => {
            if (!fgRef.current) return;
            try {
              // Read settled coords from the node objects react-force-graph mutates
              // in place — NOT fgRef.current.graphData() (not a ref method; threw +
              // silently failed since v87, so the layout cache never wrote).
              const posById = {};
              (data.nodes || []).forEach(n => {
                if (n.x != null && n.y != null) {
                  posById[n.id] = [Math.round(n.x * 100) / 100, Math.round(n.y * 100) / 100];
                }
              });
              if (Object.keys(posById).length) _btSaveLayout(topoSig, posById);
            } catch (_e) { /* non-fatal */ }
            if (!layoutReady) setLayoutReady(true);
          }}
        />
        </div>
        {/* "Back to map" pill — anchored below the mastery legend so it doesn't
            visually clash. Shown once the graph is revealed; pulls the camera back
            to the fitted-global view (also resets after cinematic open). */}
        {revealReady && (
          <button
            className="btn-secondary back-to-map-pill"
            onClick={backToMap}
            style={{
              padding: "6px 14px", fontSize: 12,
              borderRadius: 9999,
            }}
            title="Re-fit the entire knowledge map to the viewport"
          >
            Default Map View
          </button>
        )}
      </div>
    );
  }

  // ─────────── Route study surface — full-screen tutor-styled route walk ──────────
  // Left: numbered question list (1..N) + the active question card (InteractionView reused
  // verbatim) + a live recap strip. Right: live-mastery rail (bars fill/pulse on every
  // answer, flash green on a tier-up) + a lightweight SVG route mini-map. No force-graph.
  // One rail row: study-step number + node title + tier label + live MasteryBar. The rail
  // is the single numbered route list (route order), so each row carries its position number,
  // a current-position highlight, and a done state. `pulsing` (every answer) and `flashing`
  // (tier-up only) drive the CSS cues; flash wins when both apply. Clicking jumps to it.
  function RouteRailNodeRow({ num, title, mastery, attempts, isCurrent, done, pulsing, flashing, onClick }) {
    const tier = getTier(mastery, attempts);
    const cls = flashing ? "rail-row-flash" : (pulsing ? "rail-row-pulse" : "");
    return (
      <div className={cls} onClick={onClick} style={{
        border: isCurrent ? "1px solid var(--accent)" : "1px solid var(--rule)", borderRadius: 10,
        padding: "10px 12px", background: isCurrent ? "var(--hover)" : "var(--surface)", marginBottom: 8,
        cursor: "pointer", transition: "border-color 0.15s var(--easing), background 0.15s var(--easing)",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
          <span style={{
            width: 20, height: 20, borderRadius: 9999, flexShrink: 0, display: "inline-flex",
            alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 600,
            fontFamily: '"DM Mono", monospace',
            border: isCurrent ? "1px solid var(--accent)" : "1px solid var(--rule)",
            background: done ? "var(--accent)" : (isCurrent ? "var(--surface)" : "transparent"),
            color: done ? "#fff" : (isCurrent ? "var(--accent)" : "var(--muted)"),
          }}>{num}</span>
          <span title={title} style={{
            flex: 1, fontFamily: '"DM Sans", system-ui, sans-serif', fontSize: 13, fontWeight: 500, color: "var(--ink)",
            overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
          }}>{title}</span>
          <span style={{
            flexShrink: 0, fontFamily: '"DM Mono", monospace', fontSize: 9, color: TIER_COLOR[tier],
            textTransform: "uppercase", letterSpacing: "0.06em",
          }}>{TIER_LABEL[tier]}</span>
        </div>
        <MasteryBar mastery={mastery} attempts={attempts} />
      </div>
    );
  }

  // Right-rail = the single primary numbered route list, in ROUTE ORDER so position 1..N
  // matches the "Route · X/N" progress meter and the "Next topic →" advance. Not deduped or
  // re-sorted — each row IS a route step. Live mastery bars still show weakness per row.
  function RouteRail({ routePositions, currentPosition, railMastery, answeredSet, pulseNodeId, railFlashNodeId, onSelect }) {
    return (
      <div>
        {routePositions.map((p, i) => {
          const m = railMastery[p.node_id] || {};
          return (
            <RouteRailNodeRow key={i} num={i + 1} title={p.title || p.node_id}
              mastery={m.mastery_estimate ?? 0} attempts={m.attempts ?? 0}
              isCurrent={i === currentPosition}
              done={answeredSet ? answeredSet.has(i) : i < currentPosition}
              pulsing={pulseNodeId === p.node_id} flashing={railFlashNodeId === p.node_id}
              onClick={() => onSelect(i)} />
          );
        })}
      </div>
    );
  }

  // Session-length picker — a small pill group shown on the Start CTA. Changing it only
  // slices/extends the route WALK for one sitting; the working-set N (ACTIVE_CONCEPT_SET_SIZE)
  // and the mastery/spacing algorithm are never touched.
  function SessionLengthPicker({ value, onChange }) {
    return (
      <div className="session-len" role="group" aria-label="Session length">
        <span className="session-len-label">Session</span>
        {[5, 10, 15, 20].map(n => (
          <button key={n} type="button" aria-pressed={n === value}
            className={"session-len-pill" + (n === value ? " active" : "")}
            onClick={() => onChange(n)}>{n}</button>
        ))}
      </div>
    );
  }

  function RouteStudySurface({
    routePositions, currentPosition, session, interaction, answerResult, nodeInfo,
    structure, railMastery, pulseNodeId, railFlashNodeId, sessionTierCrossings,
    questionCache, error, onRetry, onDismissError,
    onSubmit, onNext, onSelectPosition, onEnd,
  }) {
    const N = routePositions.length;
    const pct = N ? Math.round(((currentPosition + 1) / N) * 100) : 0;
    const lastPosition = currentPosition >= N - 1;
    // Phase 1 (Alec's bug): answered-ness comes from the per-position review
    // cache (+ the live answerResult for the on-screen position), NOT from
    // assuming a monotonic forward walk — review navigation moves
    // currentPosition backwards without un-answering later steps.
    const answeredSet = useMemo(() => {
      const s = new Set();
      for (const [k, v] of Object.entries(questionCache || {})) {
        if (v && v.answerResult) s.add(Number(k));
      }
      if (answerResult) s.add(currentPosition);
      return s;
    }, [questionCache, answerResult, currentPosition]);
    // node_exhausted is a typed 200 payload with no .question — render the
    // exhausted card, never InteractionView (which reads interaction.question).
    const exhausted = !!interaction && interaction.interaction_type === "node_exhausted";
    const restoredSelectedIdx = (questionCache
      && questionCache[currentPosition]
      && questionCache[currentPosition].selectedIdx != null)
      ? questionCache[currentPosition].selectedIdx : null;
    // Distinct topics leveled this session (dedupe by concept, keep highest tier) —
    // powers the live recap strip. ✦ if any graduated via the spaced fast-path.
    const leveled = useMemo(() => {
      const m = new Map();
      for (const c of (sessionTierCrossings || [])) {
        const prev = m.get(c.concept);
        if (!prev || TIER_RANK[c.after_tier] > TIER_RANK[prev.after_tier]) m.set(c.concept, c);
      }
      return [...m.values()];
    }, [sessionTierCrossings]);
    const anyFastPath = leveled.some(c => c.fast_path);
    const answeredCount = answeredSet.size;
    // Live Mastery rail collapse — mirrors the AppSidebar's collapse mechanic.
    // Collapsed: 56px strip with a numbered dot stepper + leveled-count badge.
    // Mobile (≤768px): always start collapsed regardless of the saved desktop
    // preference — the expanded 280-340px rail would leave a phone-width
    // question column ~35-95px wide. Expanding on mobile overlays instead
    // (see the rail div below), so the grid never gives it a wide column.
    const isMobileView = useViewportIsMobile();
    const [railCollapsed, setRailCollapsed] = useState(() => {
      if (window.matchMedia("(max-width: 768px)").matches) return true;
      try { return localStorage.getItem("bt_rail_collapsed") === "1"; } catch (e) { return false; }
    });
    function toggleRail() {
      setRailCollapsed(v => {
        const next = !v;
        try { localStorage.setItem("bt_rail_collapsed", next ? "1" : "0"); } catch (e) {}
        return next;
      });
    }
    return (
      <div style={{
        // Offset by the PWA host's sidebar width (--bt-left-inset: 0 mobile / 64 collapsed
        // / 220 expanded) so the question column never renders under the fixed AppSidebar.
        // The sandbox .app-shell uses the same pattern; standalone leaves the var unset -> 0.
        position: "fixed", top: 0, right: 0, bottom: 0, left: "var(--bt-left-inset, 0px)",
        zIndex: 1200, background: "var(--canvas)",
        display: "grid",
        gridTemplateColumns: (railCollapsed || isMobileView) ? "1fr 56px" : "1fr minmax(280px, 340px)",
        overflow: "hidden",
      }}>
        <div className="ambient-blob" />
        {/* LEFT — header + progress + question list + active card + recap strip */}
        <div style={{ position: "relative", zIndex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
          <div style={{
            position: "sticky", top: 0, zIndex: 2, display: "flex", alignItems: "center",
            justifyContent: "space-between", padding: "12px 20px",
            background: "rgba(251,251,250,0.92)", backdropFilter: "blur(12px)",
            WebkitBackdropFilter: "blur(12px)", borderBottom: "1px solid var(--rule)",
          }}>
            <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
              <span style={{ fontFamily: '"Instrument Serif", Georgia, serif', fontSize: 20, color: "var(--ink)" }}>Brain Trace</span>
              <span style={{
                fontFamily: '"DM Mono", monospace', fontSize: 12, color: "var(--accent)",
                padding: "2px 10px", borderRadius: 9999, border: "1px solid var(--accent)",
              }}>Route · {Math.min(currentPosition + 1, N)}/{N}</span>
            </div>
            <button onClick={onEnd} title="End session" style={{
              background: "transparent", border: "1px solid var(--rule)", borderRadius: 9999,
              padding: "7px 14px", cursor: "pointer", color: "var(--muted)",
              font: '500 13px "DM Sans", system-ui, sans-serif',
            }}>End session ✕</button>
          </div>
          <div style={{ height: 3, background: "var(--rule)" }}>
            <div style={{ height: "100%", width: pct + "%", background: "var(--accent)", transition: "width 0.4s var(--easing)" }} />
          </div>
          <div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column", gap: 16, padding: "20px 24px" }}>
            <div>
              {/* Phase 1: errors render IN-PANE — the global .error-banner
                  (z-index 30) is buried under this surface (z-index 1200). */}
              {error && !interaction && (
                <div className="card" role="alert" style={{ borderLeft: "3px solid var(--red)" }}>
                  <div style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, color: "var(--red)", letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 8 }}>
                    Couldn't load this question
                  </div>
                  <div style={{ fontSize: 14, color: "var(--ink)", marginBottom: 16, lineHeight: 1.5 }}>{error}</div>
                  <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                    <button className="btn-primary" onClick={onRetry}>Try again</button>
                    <button className="btn-secondary" onClick={onEnd}>End session</button>
                  </div>
                </div>
              )}
              {error && interaction && (
                <div role="alert" style={{
                  display: "flex", alignItems: "center", gap: 10, marginBottom: 12,
                  padding: "10px 14px", background: "#FAF1F1", border: "1px solid #F0DBDB",
                  borderLeft: "3px solid var(--red)", borderRadius: 8, fontSize: 13, color: "var(--red)",
                }}>
                  <span style={{ flex: 1 }}>{error}</span>
                  <button className="btn-ghost" onClick={onDismissError} style={{ color: "var(--red)" }}>dismiss</button>
                </div>
              )}
              {!interaction && !error && (
                /* Question-card skeleton — visual structure + pulse while the
                   /next round-trip resolves, so the surface never reads as
                   frozen/click-dead. */
                <div aria-label="Loading question" role="status">
                  <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
                    <div className="bt-skel" style={{ height: 22, width: 70, borderRadius: 9999 }} />
                    <div className="bt-skel" style={{ height: 22, width: 120, borderRadius: 9999 }} />
                    <div className="bt-skel" style={{ height: 22, width: 90, borderRadius: 9999 }} />
                  </div>
                  <div className="card">
                    <div className="bt-skel" style={{ height: 16, marginBottom: 10 }} />
                    <div className="bt-skel" style={{ height: 16, marginBottom: 10, width: "92%" }} />
                    <div className="bt-skel" style={{ height: 16, marginBottom: 24, width: "60%" }} />
                    {[0, 1, 2, 3, 4].map(i => (
                      <div key={i} className="bt-skel" style={{ height: 52, marginBottom: 8, borderRadius: 8 }} />
                    ))}
                    <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 8 }}>
                      <div className="bt-skel" style={{ height: 30, width: 90, borderRadius: 9999 }} />
                      <div className="bt-skel" style={{ height: 30, width: 90, borderRadius: 9999 }} />
                      <div className="bt-skel" style={{ height: 30, width: 90, borderRadius: 9999 }} />
                    </div>
                    <div style={{ marginTop: 10, fontSize: 11, color: "var(--muted)", fontFamily: "'DM Mono', monospace", letterSpacing: "0.06em", textTransform: "uppercase" }}>
                      Selecting your highest-yield question…
                    </div>
                  </div>
                </div>
              )}
              {exhausted && (
                /* Phase 1 (Alec's bug): mid-walk exhaustion — /next returned the
                   typed node_exhausted payload (no question to render). Before
                   this card, the surface fell through to InteractionView, which
                   crashed on interaction.question and froze on the skeleton. */
                <div className="card" role="status">
                  <div style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, color: "var(--muted)", letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 8 }}>
                    Topic cleared — no more questions right now
                  </div>
                  <div style={{ fontFamily: '"Instrument Serif", Georgia, serif', fontSize: 22, color: "var(--ink)", marginBottom: 10 }}>
                    {routePositions[currentPosition]?.title || "This topic"}
                  </div>
                  <p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.6, marginBottom: 16 }}>
                    You've answered every question currently available on this topic.
                    More become eligible as review windows reopen — keep moving.
                  </p>
                  <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                    <button className="btn-primary" onClick={onNext}>
                      {lastPosition ? "Finish session →" : "Next topic →"}
                    </button>
                    <button className="btn-secondary" onClick={onEnd}>End session</button>
                  </div>
                </div>
              )}
              {interaction && !exhausted && (
                <InteractionView
                  interaction={interaction}
                  answerResult={answerResult}
                  restoredSelectedIdx={restoredSelectedIdx}
                  onSubmit={onSubmit}
                  onNext={onNext}
                  onEnd={onEnd}
                  endLabel="End session"
                  nextLabel={lastPosition ? "Finish session →" : "Next topic →"}
                />
              )}
            </div>
          </div>
          {/* Live recap strip — accumulates as topics level up during the walk. */}
          <div style={{
            borderTop: "1px solid var(--rule)", padding: "10px 24px",
            background: "rgba(251,251,250,0.92)", backdropFilter: "blur(12px)",
            WebkitBackdropFilter: "blur(12px)",
            fontFamily: '"DM Mono", monospace', fontSize: 11, color: "var(--muted)",
            display: "flex", alignItems: "center", gap: 10, flexShrink: 0,
          }}>
            <span>{answeredCount}/{N} answered</span>
            <span style={{ color: "var(--rule)" }}>·</span>
            <span style={{ color: leveled.length ? "var(--accent)" : "var(--muted)" }}>
              {leveled.length} topic{leveled.length === 1 ? "" : "s"} leveled{anyFastPath ? " ✦" : ""}
            </span>
          </div>
        </div>
        {/* RIGHT — live-mastery rail (collapsible; collapsed = dot-stepper strip).
            Mobile expanded state overlays from the right edge (fixed, capped at
            86vw) instead of occupying a grid column, so the question column
            keeps its full width underneath. */}
        <div style={{
          position: "relative", zIndex: 1, borderLeft: "1px solid var(--rule)",
          background: "var(--surface)", overflow: "auto",
          padding: railCollapsed ? "12px 6px" : "18px 14px",
          display: "flex", flexDirection: "column",
          alignItems: railCollapsed ? "center" : "stretch",
          ...(isMobileView && !railCollapsed ? {
            position: "fixed", top: 0, right: 0, bottom: 0,
            width: "min(320px, 86vw)", zIndex: 1300,
            boxShadow: "-16px 0 48px rgba(0,0,0,0.16)",
          } : null),
        }}>
          {railCollapsed ? (
            <>
              <button className="bt-rail-toggle" onClick={toggleRail} aria-label="Expand live mastery rail" title="Expand live mastery">«</button>
              <div style={{
                fontFamily: '"DM Mono", monospace', fontSize: 9, color: "var(--muted)",
                letterSpacing: "0.08em", textTransform: "uppercase",
                writingMode: "vertical-rl", margin: "10px 0 12px",
              }}>live mastery</div>
              <div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "center" }}>
                {routePositions.map((p, i) => {
                  const done = answeredSet.has(i);  // Phase 1: cache-derived, review-safe
                  const isCurrent = i === currentPosition;
                  // Compact stat cell — a shrunken version of the expanded rail row:
                  // the ring arc + the NUMERIC mastery % inside the dot, both in the
                  // tier color (same ladder as the expanded MasteryBar). The old
                  // ring-only design was invisible at low mastery (15% = a 54°
                  // sliver on a near-white track), so: the arc gets a minimum
                  // visible sweep (~10%) whenever mastery > 0, the track is darker
                  // than var(--rule) so "empty" reads as deliberate, and
                  // unattempted shows an em-dash instead of a fake 0%.
                  // Live-updates with railMastery. Step number moves to the tooltip
                  // (current step stays obvious via the ink-filled .current dot).
                  const rm = railMastery[p.node_id] || {};
                  const mastery = Math.max(0, Math.min(1, rm.mastery_estimate ?? 0));
                  const tier = getTier(mastery, rm.attempts ?? 0);
                  const pct = Math.round(mastery * 100);
                  const unattempted = tier === "unattempted";
                  const arcDeg = mastery > 0 ? Math.max(36, Math.round(mastery * 360)) : 0;
                  return (
                    <div
                      key={i}
                      className="bt-rail-ring"
                      style={{ background: `conic-gradient(${TIER_COLOR[tier]} ${arcDeg}deg, rgba(17, 17, 17, 0.10) 0deg)` }}
                      title={`Step ${i + 1} · ${p.title || "—"} — ${unattempted ? "not yet attempted" : `${pct}% · ${TIER_LABEL[tier]}`}`}
                    >
                      <button
                        className={`bt-rail-dot${isCurrent ? " current" : ""}${done && !isCurrent ? " done" : ""}`}
                        style={isCurrent ? undefined : { color: unattempted ? "var(--muted)" : TIER_COLOR[tier] }}
                        onClick={() => onSelectPosition && onSelectPosition(i)}
                        aria-label={`Route step ${i + 1} — ${unattempted ? "not yet attempted" : `${pct}% mastery`}`}
                      >{unattempted ? "—" : pct}</button>
                    </div>
                  );
                })}
              </div>
              {leveled.length > 0 && (
                <div
                  style={{ marginTop: 12, fontFamily: '"DM Mono", monospace', fontSize: 10, color: "var(--accent)" }}
                  title={`${leveled.length} topic${leveled.length === 1 ? "" : "s"} leveled this session`}
                >{leveled.length}▲{anyFastPath ? "✦" : ""}</div>
              )}
            </>
          ) : (
            <>
              <div style={{
                display: "flex", alignItems: "baseline", gap: 8,
                borderBottom: "2px solid var(--accent)", paddingBottom: 6, marginBottom: 14,
              }}>
                <span style={{ fontFamily: '"Instrument Serif", Georgia, serif', fontSize: 18, color: "var(--ink)" }}>Brain Trace</span>
                <span style={{
                  fontFamily: '"DM Mono", monospace', fontSize: 9, color: "var(--muted)",
                  letterSpacing: "0.08em", textTransform: "uppercase",
                }}>live mastery</span>
                <button className="bt-rail-toggle" onClick={toggleRail} aria-label="Collapse live mastery rail" title="Collapse live mastery" style={{ marginLeft: "auto" }}>»</button>
              </div>
              <RouteRail routePositions={routePositions} currentPosition={currentPosition} railMastery={railMastery}
                answeredSet={answeredSet}
                pulseNodeId={pulseNodeId} railFlashNodeId={railFlashNodeId} onSelect={onSelectPosition} />
            </>
          )}
        </div>
      </div>
    );
  }

  // ─────────── Study overlay (frosted glass) ───────────

  function StudyOverlay({
    nodeId, nodeInfo, interaction, answerResult, onSubmit, onNext, onClose,
    walkthroughText, walkthroughLoading, walkthroughError,
    onWalkthrough, onStudyRelated, onMarkNeedsWork,
    annotation, isAttention, onSaveAnnotation, onToggleAttention, onStartLinking,
    onTeachBack, onOpenFlashcards,
  }) {
    // Issue A: the cleared/stuck card fires for ANY node with no servable
    // questions — all answered (any mastery) OR none mapped — plus the mid-session
    // case where /next reports node_exhausted after the last question.
    const isStuck = !!nodeInfo && (
      !nodeInfo.has_questions
      || nodeInfo.exhausted_and_weak
      || interaction?.interaction_type === "node_exhausted"
    );
    return (
      <div className="overlay-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
        <div className="overlay-panel">
          <div className="overlay-header" style={{ position: "relative" }}>
            <div className="eyebrow">{isStuck ? "Stuck node — no questions available" : "Studying node"}</div>
            <h2>{nodeInfo?.title || "Loading…"}</h2>
            {nodeInfo && (
              <div className="pills-row">
                <span className="pill">{nodeInfo.psite_section}</span>
                {nodeInfo.tested_frequency && <span className="pill">tf: {nodeInfo.tested_frequency}</span>}
                {nodeInfo.difficulty && <span className="pill">{nodeInfo.difficulty}</span>}
                <span className="pill">
                  {nodeInfo.attempts > 0
                    ? `mastery ${nodeInfo.mastery_estimate.toFixed(2)} · ${nodeInfo.attempts} attempts`
                    : "unattempted"}
                </span>
                {nodeInfo.eligible_question_count != null && (
                  <span className="pill">
                    {nodeInfo.eligible_question_count === 0
                      ? "0 q's available"
                      : `${nodeInfo.eligible_question_count} q's mapped`}
                  </span>
                )}
                {isAttention && <span className="pill" style={{ background: "#FFF6E5", color: "var(--warn)", borderColor: "var(--warn)" }}>★ flagged</span>}
                {annotation && <span className="pill" style={{ background: "#F1EEE2" }}>📝 noted</span>}
              </div>
            )}
            {nodeInfo && (
              <div style={{ marginTop: 10 }}>
                <MasteryBar mastery={nodeInfo.mastery_estimate} attempts={nodeInfo.attempts} />
              </div>
            )}
            <button className="close-btn" onClick={onClose} title="Close">×</button>
          </div>
          <div className="overlay-body">
            <ConstructionPanel
              nodeId={nodeId}
              annotation={annotation}
              isAttention={isAttention}
              onSaveAnnotation={onSaveAnnotation}
              onToggleAttention={onToggleAttention}
              onStartLinking={onStartLinking}
            />
            {isStuck ? (
              <StuckNodeCard
                nodeInfo={nodeInfo}
                walkthroughText={walkthroughText}
                walkthroughLoading={walkthroughLoading}
                walkthroughError={walkthroughError}
                onWalkthrough={onWalkthrough}
                onStudyRelated={onStudyRelated}
                onMarkNeedsWork={onMarkNeedsWork}
                onClose={onClose}
              />
            ) : (
              <>
                {/* v107 (Item 5 target design): every node offers all three
                    study modes — practice questions (default, below), topic
                    notes (question-bank-grounded synthesis, cached 24h), and
                    flashcards (now the PWA Flash Cards tab; this button
                    navigates there). Questions stay primary; the row is a
                    compact secondary affordance. */}
                <div style={{ display: "flex", gap: 8, marginBottom: 14, flexWrap: "wrap" }}>
                  <button
                    className="btn-secondary"
                    style={{ padding: "6px 12px", fontSize: 12 }}
                    onClick={onWalkthrough}
                    disabled={walkthroughLoading || !!walkthroughText}
                  >
                    {walkthroughLoading
                      ? "Generating overview…"
                      : walkthroughText ? "✓ AI overview below" : "AI overview"}
                  </button>
                  {onOpenFlashcards && (
                    <button
                      className="btn-secondary"
                      style={{ padding: "6px 12px", fontSize: 12 }}
                      onClick={onOpenFlashcards}
                    >
                      ✦ Flashcards
                    </button>
                  )}
                </div>
                {!interaction && <div style={{ color: "var(--muted)", fontStyle: "italic" }}>Picking your first question on this node…</div>}
                {interaction && (
                  <InteractionView
                    interaction={interaction}
                    answerResult={answerResult}
                    onSubmit={onSubmit}
                    onNext={onNext}
                    onEnd={onClose}
                    endLabel="Done with this node"
                    onTeachBack={onTeachBack}
                  />
                )}
                {walkthroughError && (
                  <div className="card" style={{ borderLeft: "3px solid var(--red)", color: "var(--red)", marginTop: 16 }}>
                    Overview generation failed: {walkthroughError}
                  </div>
                )}
                {walkthroughText && (
                  <div className="card walkthrough" style={{ marginTop: 16 }}>
                    <div className="eyebrow" style={{ color: "var(--accent)", fontFamily: "'DM Mono', monospace", fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase", marginBottom: 12 }}>
                      AI-generated overview · may draw on general medical knowledge
                    </div>
                    <MarkdownText text={walkthroughText} />
                  </div>
                )}
              </>
            )}
          </div>
        </div>
      </div>
    );
  }

  // ─────────── Construction panel — annotations, attention flag, draw edge ───────────
  // Surfaces inside the StudyOverlay header/body. Three controls turn passive
  // navigation into active map authoring (Nesbit & Adesope 2006).
  function ConstructionPanel({ nodeId, annotation, isAttention, onSaveAnnotation, onToggleAttention, onStartLinking }) {
    const [draft, setDraft] = useState(annotation || "");
    const [expanded, setExpanded] = useState(!!annotation);  /* auto-open if a note already exists */

    // Resync draft when the node (or its annotation) changes
    useEffect(() => { setDraft(annotation || ""); }, [annotation, nodeId]);

    const dirty = (draft || "") !== (annotation || "");

    return (
      <div className="construction-panel">
        <div className="construction-actions">
          <button
            className={`btn-secondary ${isAttention ? "is-active" : ""}`}
            onClick={onToggleAttention}
            title="Manually flag this concept so it surfaces on your active route."
          >
            {isAttention ? "★ Flagged for attention" : "☆ Flag for attention"}
          </button>
          <button
            className="btn-secondary"
            onClick={onStartLinking}
            title="Draw a personal edge from this node to another. Closes the panel; click another node on the graph to complete the connection."
          >
            ↗ Draw a personal connection
          </button>
          <button
            className="btn-ghost"
            onClick={() => setExpanded((v) => !v)}
            title="Per-node note — captures clinical insights or things to revisit."
          >
            {annotation ? "📝 View / edit note" : (expanded ? "Hide note editor" : "+ Add a note")}
          </button>
        </div>
        {expanded && (
          <div className="construction-note">
            <textarea
              value={draft}
              onChange={(e) => setDraft(e.target.value)}
              placeholder="Anything you want to remember about this concept — clinical pearls, mnemonics, things you want to revisit. Saved per-node, only to you."
              rows={4}
            />
            <div className="construction-note-actions">
              <button
                className="btn-primary"
                onClick={() => onSaveAnnotation(draft)}
                disabled={!dirty}
              >
                {annotation ? "Save changes" : "Save note"}
              </button>
              {annotation && (
                <button
                  className="btn-ghost"
                  onClick={() => { setDraft(""); onSaveAnnotation(""); }}
                  style={{ color: "var(--red)" }}
                >
                  Delete note
                </button>
              )}
            </div>
          </div>
        )}
      </div>
    );
  }

  // ─────────── Right-click context menu (Slice D-polish v4) ───────────
  // Small frosted bar that pops up at the cursor when a node is right-clicked.
  // Three CTAs: flag for attention, add/edit note, draw a personal connection.
  // Auto-dismisses on outside click, Escape key, or scroll/resize.
  function NodeContextMenu({ nodeId, title, x, y, isAttention, hasAnnotation, onAttention, onNote, onLink, onDismiss }) {
    const ref = useRef(null);

    useEffect(() => {
      const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) onDismiss(); };
      const onKey = (e) => { if (e.key === "Escape") onDismiss(); };
      const onResize = () => onDismiss();
      // Defer doc listener by one tick so the right-click event that opened
      // the menu doesn't immediately dismiss it.
      const t = setTimeout(() => document.addEventListener("mousedown", onDoc), 0);
      window.addEventListener("keydown", onKey);
      window.addEventListener("resize", onResize);
      window.addEventListener("scroll", onResize, true);
      return () => {
        clearTimeout(t);
        document.removeEventListener("mousedown", onDoc);
        window.removeEventListener("keydown", onKey);
        window.removeEventListener("resize", onResize);
        window.removeEventListener("scroll", onResize, true);
      };
    }, [onDismiss]);

    // Clamp to viewport with a small margin
    const W = 280, H = 132, M = 8;
    const left = Math.min(window.innerWidth - W - M, Math.max(M, x));
    const top = Math.min(window.innerHeight - H - M, Math.max(M, y));

    return (
      <div
        ref={ref}
        className="node-context-menu"
        style={{ position: "fixed", left, top, width: W, zIndex: 50 }}
      >
        <div className="node-context-title" title={title}>{title}</div>
        <button
          className={`node-context-action ${isAttention ? "is-active" : ""}`}
          onClick={onAttention}
        >
          {isAttention ? "★ Unflag from attention" : "☆ Flag for attention"}
        </button>
        <button className="node-context-action" onClick={onNote}>
          {hasAnnotation ? "📝 View / edit note" : "📝 Add a note"}
        </button>
        <button className="node-context-action" onClick={onLink}>
          ↗ Draw a personal connection
        </button>
      </div>
    );
  }

  // ─────────── Personal-edge right-click menu (Slice D-polish v4) ───────────
  // Right-click any personal (dashed accent) edge → "Delete this connection."
  // Vault-canonical edges are not interactive — only user-drawn edges get this.
  function LinkContextMenu({ edgeId, title, x, y, onDelete, onDismiss }) {
    const ref = useRef(null);
    useEffect(() => {
      const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) onDismiss(); };
      const onKey = (e) => { if (e.key === "Escape") onDismiss(); };
      const onResize = () => onDismiss();
      const t = setTimeout(() => document.addEventListener("mousedown", onDoc), 0);
      window.addEventListener("keydown", onKey);
      window.addEventListener("resize", onResize);
      window.addEventListener("scroll", onResize, true);
      return () => {
        clearTimeout(t);
        document.removeEventListener("mousedown", onDoc);
        window.removeEventListener("keydown", onKey);
        window.removeEventListener("resize", onResize);
        window.removeEventListener("scroll", onResize, true);
      };
    }, [onDismiss]);
    const W = 280, H = 96, M = 8;
    const left = Math.min(window.innerWidth - W - M, Math.max(M, x));
    const top = Math.min(window.innerHeight - H - M, Math.max(M, y));
    return (
      <div ref={ref} className="node-context-menu" style={{ position: "fixed", left, top, width: W, zIndex: 50 }}>
        <div className="node-context-title" title={title}>{title}</div>
        <button
          className="node-context-action"
          onClick={onDelete}
          style={{ color: "var(--red, #B5634C)" }}
        >
          ✕ Delete this personal connection
        </button>
      </div>
    );
  }

  // ─────────── Reset Brain Trace modal ───────────
  // Destructive action — wipes all of the user's accumulated mastery, observations,
  // sessions, notes, attention flags, personal edges, generated content. Friction
  // gate: user must literally type "RESET" before the confirm button enables. After
  // confirmation, the modal briefly shows a deletion receipt (which tables were
  // wiped) before the page hard-reloads to a true cold-start state.
  //
  // Cohesive-ecosystem positioning: this modal's copy explicitly tells the user
  // that the brain-trace and the question bank share memory, so resetting here
  // also resets question-bank progress. Today (sandbox) that's already true
  // structurally — question_progress is one of the wiped tables. When this ports
  // into incise_app, that promise will be reflected across both surfaces.
  function ResetBrainTraceModal({ busy, report, onCancel, onConfirm }) {
    const [typed, setTyped] = useState("");
    const matched = typed.trim().toUpperCase() === "RESET";
    const inputRef = useRef(null);

    // Focus the input when the modal opens, so keyboard users can start typing
    // immediately. ESC cancels (but only if not busy — don't let a partial reset
    // be aborted mid-flight from the UI).
    useEffect(() => {
      const t = setTimeout(() => inputRef.current?.focus(), 60);
      const onKey = (e) => {
        if (e.key === "Escape" && !busy && !report) onCancel();
        if (e.key === "Enter" && matched && !busy && !report) onConfirm();
      };
      window.addEventListener("keydown", onKey);
      return () => { clearTimeout(t); window.removeEventListener("keydown", onKey); };
    }, [matched, busy, report, onCancel, onConfirm]);

    // Total rows wiped, derived from the deletion report. Excludes preserved tables.
    const totalDeleted = report
      ? Object.values(report.deleted || {}).reduce((s, n) => s + (n || 0), 0)
      : 0;

    return (
      <div
        className="reset-modal"
        onClick={(e) => { if (e.target === e.currentTarget && !busy && !report) onCancel(); }}
      >
        <div className="reset-card" role="dialog" aria-modal="true" aria-labelledby="reset-title">
          <div className="eyebrow">Permanent action</div>
          <h2 id="reset-title">Reset Brain Trace?</h2>
          {!report && (
            <>
              <p>
                This permanently erases everything Brain Trace has learned about you
                — your mastery on every concept, your question history, your notes,
                your route, your misconception flags, your generated content. The
                map of plastic surgery itself stays — every node returns to grey,
                awaiting your next study session.
              </p>
              <p>
                Brain Trace and your question bank share the same memory.{" "}
                <strong>Resetting here also resets your question-bank progress
                in Incise.</strong> This cannot be undone.
              </p>
              <label className="confirm-label" htmlFor="reset-confirm-input">
                Type RESET to confirm
              </label>
              <input
                id="reset-confirm-input"
                ref={inputRef}
                type="text"
                value={typed}
                onChange={(e) => setTyped(e.target.value)}
                placeholder="RESET"
                className={matched ? "matched" : ""}
                disabled={busy}
                autoComplete="off"
                spellCheck={false}
              />
              <div className="actions">
                <button
                  className="btn-cancel"
                  onClick={onCancel}
                  disabled={busy}
                  type="button"
                >
                  Cancel
                </button>
                <button
                  className="btn-confirm-reset"
                  onClick={onConfirm}
                  disabled={!matched || busy}
                  type="button"
                >
                  {busy ? "Resetting…" : "Reset everything"}
                </button>
              </div>
            </>
          )}
          {report && (
            <>
              <p style={{ color: "var(--green)" }}>
                Reset complete. Wiped <strong style={{ color: "var(--green)" }}>{totalDeleted}</strong>{" "}
                row{totalDeleted === 1 ? "" : "s"} across {Object.keys(report.deleted || {}).length} tables.
                Reloading to a fresh map…
              </p>
              {Object.keys(report.deleted || {}).length > 0 && (
                <div className="deletion-summary">
                  {Object.entries(report.deleted)
                    .filter(([, n]) => (n || 0) > 0)
                    .map(([table, n]) => (
                      <div key={table}>{table}: {n}</div>
                    ))}
                  {Object.values(report.deleted).every((n) => !n) && (
                    <div>Brain Trace was already at a clean state.</div>
                  )}
                </div>
              )}
            </>
          )}
        </div>
      </div>
    );
  }

  // ─────────── Rapid Review modal (Slice D-polish v4) ───────────
  // Once-per-week cram packet built from the user's weak nodes + personal notes.
  // Three states: pre-generation (eligibility + counts), in-flight (busy), post-generation
  // (preview + .docx download + cooldown banner).
  function RapidReviewModal({ status, result, busy, error, onGenerate, onClose }) {
    const eligible = status?.eligible ?? false;
    const reason = status?.reason;
    const cooldownDays = Math.ceil((status?.available_in_seconds || 0) / 86400);

    return (
      <div className="overlay-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
        <div className="rapid-review-panel">
          <div className="overlay-header" style={{ position: "relative" }}>
            <div className="eyebrow">Rapid Review · Cornell method</div>
            <h2>Generate a cram packet from your weak nodes + personal notes.</h2>
            <p style={{ fontSize: 13, color: "var(--muted)", marginTop: 4 }}>
              Synthesizes everything you've written + every node you're genuinely
              weak on (mastery &lt; 0.30, ≥2 attempts) into a self-testable Cornell-format study sheet.
              Available once per week.
            </p>
            <button className="close-btn" onClick={onClose} title="Close">×</button>
          </div>
          <div className="overlay-body">
            {!result && (
              <>
                {status && (
                  <div className="card" style={{ marginBottom: 14 }}>
                    <div className="rapid-review-stat-row">
                      <div>
                        <div className="rapid-review-stat-label">Weak nodes</div>
                        <div className="rapid-review-stat-value">{status.weak_node_count}</div>
                      </div>
                      <div>
                        <div className="rapid-review-stat-label">Annotated nodes</div>
                        <div className="rapid-review-stat-value">{status.annotated_node_count}</div>
                      </div>
                      {status.last && (
                        <div>
                          <div className="rapid-review-stat-label">Last generated</div>
                          <div className="rapid-review-stat-value" style={{ fontSize: 14 }}>
                            {status.last.days_ago < 1
                              ? "today"
                              : `${Math.round(status.last.days_ago)} day${status.last.days_ago < 2 ? "" : "s"} ago`}
                          </div>
                        </div>
                      )}
                    </div>
                  </div>
                )}
                {!eligible && reason === "cooldown_active" && (
                  <div className="card" style={{ borderLeft: "3px solid var(--warn)" }}>
                    <strong>Cooldown active.</strong> Rapid Review is throttled to once per
                    {" "}{status.cooldown_days} days to control LLM cost. Available again in
                    {" "}<strong>{cooldownDays} day{cooldownDays === 1 ? "" : "s"}</strong>.
                  </div>
                )}
                {!eligible && reason === "no_inputs" && (
                  <div className="card" style={{ borderLeft: "3px solid var(--warn)" }}>
                    <strong>Nothing to synthesize yet.</strong> Answer some questions
                    (so the system can identify weak nodes) or pin a personal note to
                    a node, then come back.
                  </div>
                )}
                {error && (
                  <div className="card" style={{ borderLeft: "3px solid var(--red)", color: "var(--red)" }}>
                    Generation failed: {error}
                  </div>
                )}
                <div style={{ display: "flex", justifyContent: "flex-end", gap: 12, marginTop: 14 }}>
                  <button className="btn-ghost" onClick={onClose} disabled={busy}>Close</button>
                  <button
                    className="btn-primary"
                    onClick={onGenerate}
                    disabled={busy || !eligible}
                  >
                    {busy ? "Generating… (~30–60s)" : "Generate Rapid Review Notes →"}
                  </button>
                </div>
              </>
            )}

            {result && (
              <>
                <div className="card" style={{ borderLeft: "3px solid var(--accent)", marginBottom: 14 }}>
                  <strong>Packet ready.</strong> {result.topic_count} topic{result.topic_count === 1 ? "" : "s"} synthesized
                  from {result.weak_node_count} weak node{result.weak_node_count === 1 ? "" : "s"}
                  {" + "}{result.note_node_count} personal note{result.note_node_count === 1 ? "" : "s"}.
                </div>
                <div style={{ display: "flex", gap: 12, marginBottom: 14 }}>
                  {(() => {
                    // Host divergence: in the PWA (detected by the
                    // __brainTraceApiFetch shim) the packet opens in the Study
                    // Notes tab — the .docx anchor can't work there (relative
                    // URL resolves against the Vercel origin, and <a download>
                    // can't carry the JWT). The standalone sandbox (same
                    // origin, no JWT) keeps the working download anchor, and
                    // it's also the fallback if the backend predates
                    // study_note_id.
                    const inPwa = typeof window.__brainTraceApiFetch === "function";
                    if (inPwa && result.study_note_id != null) {
                      return (
                        <button
                          className="btn-primary"
                          style={{ padding: "12px 18px" }}
                          onClick={() => {
                            window.dispatchEvent(new CustomEvent("incise-navigate", {
                              detail: { section: "study-notes", noteId: result.study_note_id },
                            }));
                            onClose();
                          }}
                        >
                          Open in Study Notes →
                        </button>
                      );
                    }
                    return (
                      <a
                        className="btn-primary"
                        href={result.download_url}
                        download
                        style={{ textDecoration: "none", display: "inline-flex", alignItems: "center", padding: "12px 18px" }}
                      >
                        Download .docx ↓
                      </a>
                    );
                  })()}
                  <button className="btn-secondary" onClick={onClose}>Done</button>
                </div>
                <div className="rapid-review-preview">
                  <MarkdownText text={result.preview_markdown} />
                </div>
              </>
            )}
          </div>
        </div>
      </div>
    );
  }

  // ─────────── Stuck-node card (Day 4 Slice D4) ───────────
  function StuckNodeCard({ nodeInfo, walkthroughText, walkthroughLoading, walkthroughError, onWalkthrough, onStudyRelated, onMarkNeedsWork, onClose }) {
    // Issue A: adaptive cleared/stuck framing. "Cleared" = worked through every
    // available question and not weak (positive, on-thesis: move to the next weak
    // concept). "Stuck" = weak, or no questions mapped at all (vault gap).
    const mastery = Number(nodeInfo?.mastery_estimate) || 0;
    const attempted = (nodeInfo?.attempts || 0) > 0;
    const noMapped = (nodeInfo?.mapped_concept_count || 0) === 0;
    const cleared = attempted && !noMapped && mastery >= 0.4;
    const accent = cleared ? "var(--accent)" : "var(--warn)";
    const eyebrow = noMapped
      ? "No questions mapped yet"
      : cleared ? "Topic cleared" : "Why you're stuck on this node";
    const studyRelatedBtn = (
      <button
        key="related"
        className={cleared ? "btn-primary" : "btn-secondary"}
        onClick={onStudyRelated}
        style={{ textAlign: "left" }}
      >
        Study the next weakest concept on your route →
      </button>
    );
    const walkthroughBtn = (
      <button
        key="walkthrough"
        className={cleared ? "btn-secondary" : "btn-primary"}
        onClick={onWalkthrough}
        disabled={walkthroughLoading || !!walkthroughText}
        style={{ textAlign: "left" }}
      >
        {/* v107 label (post-proof): the walkthrough is NOT provably grounded in
            the question bank — on a node with no mapped questions the grounding
            block is the empty-sentinel and the model generates from its own
            training knowledge (see backend/llm/walkthrough.py system prompt).
            So the honest label is "AI-generated overview", never a
            question-bank-provenance claim. */}
        {walkthroughLoading
          ? "Generating overview…  (~3s)"
          : walkthroughText ? "✓ AI overview loaded below" : "Teach me this — AI overview →"}
      </button>
    );
    return (
      <div>
        <div className="card" style={{ borderLeft: "3px solid " + accent }}>
          <div className="eyebrow" style={{ color: accent, fontFamily: "'DM Mono', monospace", fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase", marginBottom: 12 }}>
            {eyebrow}
          </div>
          <MasteryBar mastery={nodeInfo?.mastery_estimate} attempts={nodeInfo?.attempts} />
          <p style={{ fontSize: 14, lineHeight: 1.6, marginTop: 16 }}>
            {noMapped
              ? <>No questions in the bank are mapped to this vault topic yet (<em>vault expansion gap</em>). Keep building your model another way:</>
              : cleared
                ? <>You've worked through <strong>every available question</strong> on this topic (mastery {mastery.toFixed(2)}, {nodeInfo.attempts} attempts). Nicely cleared — keep the momentum:</>
                : <>You're <strong>weak</strong> here (mastery {mastery.toFixed(2)}, {nodeInfo.attempts || 0} prior attempts) <strong>and</strong> every available question is in the 30-day no-repeat window. A few ways to keep moving:</>}
          </p>
          <div style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 16 }}>
            {cleared ? [studyRelatedBtn, walkthroughBtn] : [walkthroughBtn, studyRelatedBtn]}
            <button key="needswork" className="btn-ghost" onClick={onMarkNeedsWork} style={{ textAlign: "left", justifyContent: "flex-start" }}>
              Mark as needs work (signal vault-expansion priority)
            </button>
          </div>
          <p style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.5, marginTop: 14, marginBottom: 0 }}>
            You can also generate <strong>flashcards</strong> or <strong>rapid-review notes</strong> for this area from the bar at the bottom of the screen.
          </p>
        </div>

        {walkthroughError && (
          <div className="card" style={{ borderLeft: "3px solid var(--red)", color: "var(--red)" }}>
            Walkthrough generation failed: {walkthroughError}
          </div>
        )}

        {walkthroughText && (
          <div className="card walkthrough">
            <div className="eyebrow" style={{ color: "var(--accent)", fontFamily: "'DM Mono', monospace", fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase", marginBottom: 12 }}>
              AI-generated overview · may draw on general medical knowledge
            </div>
            <MarkdownText text={walkthroughText} />
          </div>
        )}

        <div style={{ marginTop: 24, textAlign: "center" }}>
          <button className="btn-secondary" onClick={onClose}>
            Done with this node
          </button>
        </div>
      </div>
    );
  }

  // Safe markdown renderer using React elements only (no dangerouslySetInnerHTML).
  // Supports: # / ## / ### headings, paragraphs, **bold** inline, bullet lists,
  // GitHub-flavored markdown tables (| col | col | + separator row), block quotes (>).
  // Sufficient for both the walkthrough format and the Rapid Review packet.
  function MarkdownText({ text }) {
    if (!text) return null;
    const blocks = text.split(/\n\n+/);
    return (
      <div style={{ fontSize: 14, lineHeight: 1.65 }}>
        {blocks.map((block, i) => {
          const trimmed = block.trim();
          if (!trimmed) return null;
          // Heading?
          const headingMatch = trimmed.match(/^(#{1,3})\s+(.*)$/);
          if (headingMatch && trimmed.split("\n").length === 1) {
            const level = headingMatch[1].length;
            const txt = headingMatch[2];
            const sizes = { 1: 22, 2: 18, 3: 15 };
            return (
              <div
                key={i}
                style={{
                  fontFamily: level === 1 ? "'Instrument Serif', serif" : "'DM Sans', sans-serif",
                  fontWeight: level === 1 ? 400 : 600,
                  fontSize: sizes[level],
                  margin: "16px 0 8px",
                }}
              >
                {renderInline(txt)}
              </div>
            );
          }
          // Table? (lines start with `|`, second line is `| --- | --- |`)
          const lines = trimmed.split("\n");
          const tableMatch =
            lines.length >= 2
            && lines[0].startsWith("|")
            && /^\|\s*[-:]+(\s*\|\s*[-:]+)+\s*\|?\s*$/.test(lines[1]);
          if (tableMatch) {
            const parseRow = (l) =>
              l.replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
            const headers = parseRow(lines[0]);
            const rows = lines.slice(2).map(parseRow);
            return (
              <div key={i} style={{ overflowX: "auto", margin: "10px 0 14px" }}>
                <table style={{ borderCollapse: "collapse", width: "100%", fontSize: 13 }}>
                  <thead>
                    <tr>
                      {headers.map((h, j) => (
                        <th key={j} style={{
                          textAlign: "left", padding: "6px 10px",
                          borderBottom: "2px solid var(--rule)",
                          background: "#F7F6F3",
                          fontWeight: 600,
                        }}>{renderInline(h)}</th>
                      ))}
                    </tr>
                  </thead>
                  <tbody>
                    {rows.map((row, ri) => (
                      <tr key={ri}>
                        {row.map((c, ci) => (
                          <td key={ci} style={{ padding: "6px 10px", borderBottom: "1px solid var(--rule)" }}>
                            {renderInline(c.replace(/\\\|/g, "|"))}
                          </td>
                        ))}
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            );
          }
          // Block quote
          if (/^>\s/.test(trimmed)) {
            const inner = trimmed.split(/\n/).map((l) => l.replace(/^>\s?/, "")).join("\n");
            return (
              <div key={i} style={{
                borderLeft: "3px solid var(--accent)",
                paddingLeft: 12, color: "var(--ink)",
                fontStyle: "italic", margin: "10px 0 14px",
              }}>
                {renderInline(inner)}
              </div>
            );
          }
          // Bullet list
          if (/^[\-•]\s/.test(trimmed)) {
            const items = trimmed.split(/\n/).map((l) => l.replace(/^[\-•]\s+/, "").trim());
            return (
              <ul key={i} style={{ marginLeft: 20, marginBottom: 14, paddingLeft: 0 }}>
                {items.map((item, j) => <li key={j} style={{ marginBottom: 4 }}>{renderInline(item)}</li>)}
              </ul>
            );
          }
          return <p key={i} style={{ marginBottom: 14 }}>{renderInline(trimmed)}</p>;
        })}
      </div>
    );
  }

  // Inline parser for **bold**. Returns an array of strings/elements.
  function renderInline(text) {
    const parts = text.split(/(\*\*[^*]+\*\*)/);
    return parts.map((p, i) => {
      if (p.startsWith("**") && p.endsWith("**")) {
        return <strong key={i}>{p.slice(2, -2)}</strong>;
      }
      // Preserve hard line breaks within paragraphs
      const lines = p.split("\n");
      return lines.map((line, j) => (
        <React.Fragment key={`${i}-${j}`}>
          {line}{j < lines.length - 1 && <br />}
        </React.Fragment>
      ));
    });
  }

  // ─────────── Interaction view (MCQ) — reused unchanged from Day 2 ───────────
  // Draggable, closable tool modal. Portals to document.body so it escapes the
  // study-surface stacking context (the Live Mastery rail was painting over the
  // in-card modals and swallowing their clicks). Drag by the header; z-index sits
  // above the RouteStudySurface overlay (1200).
  function BtToolModal({ title, onClose, className = "", children }) {
    const [pos, setPos] = useState(null); // {top, left} once dragged; null = CSS default
    const cardRef = useRef(null);
    function onHeaderPointerDown(e) {
      if (e.button !== undefined && e.button !== 0) return;
      const el = cardRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const offX = e.clientX - rect.left;
      const offY = e.clientY - rect.top;
      function move(ev) {
        setPos({
          top: Math.max(4, Math.min(window.innerHeight - 48, ev.clientY - offY)),
          left: Math.max(4, Math.min(window.innerWidth - 60, ev.clientX - offX)),
        });
      }
      function up() {
        window.removeEventListener("pointermove", move);
        window.removeEventListener("pointerup", up);
      }
      window.addEventListener("pointermove", move);
      window.addEventListener("pointerup", up);
      e.preventDefault();
    }
    return ReactDOM.createPortal(
      <div
        ref={cardRef}
        className={`bt-tool-modal ${className}`}
        style={pos ? { top: pos.top, left: pos.left, right: "auto" } : undefined}
        role="dialog"
        aria-label={title}
      >
        <div className="bt-tool-modal-head" onPointerDown={onHeaderPointerDown}>
          <span className="bt-tool-modal-title">{title}</span>
          <button
            className="bt-tool-modal-close"
            onPointerDown={(e) => e.stopPropagation()}
            onClick={onClose}
            aria-label={`Close ${title}`}
          >✕</button>
        </div>
        {children}
      </div>,
      document.body
    );
  }

  function InteractionView({ interaction, answerResult, restoredSelectedIdx = null, onSubmit, onNext, onEnd, endLabel = "End Session", hideAnalysisStatus = false, nextLabel = "Next Question →", onTeachBack }) {
    const [selectedIdx, setSelectedIdx] = useState(null);
    const [confidence, setConfidence] = useState(null);
    // Phase C tools — persist across questions within a session (mirrors QuizPage's
    // tool state living above the per-question render). Notes are keyed per question.
    const [textZoom, setTextZoom] = useState(1);
    const [showLab, setShowLab] = useState(false);
    const [showNotes, setShowNotes] = useState(false);
    const [showHelp, setShowHelp] = useState(false);
    const [notes, setNotes] = useState({});
    // Pre-submit distractor elimination (mirrors Tutor's strikeout). Keyed by
    // choice index; reset per question.
    const [struck, setStruck] = useState({});
    // Post-reveal "Why not?" expansion for unselected wrong distractors +
    // reveal/collapse-all toggle (mirrors Tutor). Keyed by choice index.
    const [expandedDistractors, setExpandedDistractors] = useState({});
    const [revealAll, setRevealAll] = useState(false);
    // v107: stem text-highlighting, ported from the Tutor/Test QuizPage
    // implementation (offset-keyed ranges scoped to the stem container, so
    // only the exact selected occurrence is marked). Session-scoped, reset
    // per question — parity with Tutor's per-question highlight lifecycle.
    const [highlights, setHighlights] = useState([]);
    const stemRef = useRef(null);
    const startedAtRef = useRef(Date.now());

    useEffect(() => {
      // Phase 1 (Alec's bug): a cached review restore re-seeds the learner's
      // original pick (the red "your wrong pick" rendering keys off
      // selectedIdx, which this reset would otherwise wipe). Fresh draws pass
      // no restoredSelectedIdx and reset to null as before.
      setSelectedIdx(restoredSelectedIdx != null ? restoredSelectedIdx : null);
      setConfidence(null);
      setStruck({});
      setExpandedDistractors({});
      setRevealAll(false);
      setHighlights([]);
      startedAtRef.current = Date.now();
    }, [interaction]);

    const captureStemHighlight = () => {
      const sel = window.getSelection();
      if (!sel || sel.isCollapsed) return;
      const raw = sel.toString();
      const text = raw.trim();
      if (!text || text.length < 2) return;
      if (!stemRef.current) return;
      const range = sel.getRangeAt(0);
      if (!stemRef.current.contains(range.commonAncestorContainer)) return;
      // Character offset of the selection start within the stem's text content.
      // <mark> elements add no characters, so offsets map directly onto q.stem.
      const pre = range.cloneRange();
      pre.selectNodeContents(stemRef.current);
      pre.setEnd(range.startContainer, range.startOffset);
      const leadTrim = raw.length - raw.trimStart().length;
      const start = pre.toString().length + leadTrim;
      const end = start + text.length;
      setHighlights(prev =>
        prev.some(h => h.start === start && h.end === end) ? prev : [...prev, { start, end, text }]
      );
      sel.removeAllRanges();
    };
    const removeStemHighlight = (entry) => {
      setHighlights(prev => prev.filter(h => !(h.start === entry.start && h.end === entry.end)));
    };
    const renderStemWithHighlights = (text) => {
      if (!highlights.length) return text;
      const ranges = highlights
        .filter(h => h.start >= 0 && h.end > h.start && h.end <= text.length
          && text.slice(h.start, h.end) === h.text)
        .sort((a, b) => a.start - b.start);
      if (!ranges.length) return text;
      const out = [];
      let cursor = 0;
      ranges.forEach((r, i) => {
        if (r.start < cursor) return;  /* skip overlapping ranges */
        if (r.start > cursor) out.push(text.slice(cursor, r.start));
        out.push(
          <mark
            key={i}
            onClick={(e) => { e.stopPropagation(); removeStemHighlight(r); }}
            style={{ background: "#fef08a", borderRadius: 2, padding: "0 1px", cursor: "pointer" }}
            title="Click to remove highlight"
          >{text.slice(r.start, r.end)}</mark>
        );
        cursor = r.end;
      });
      if (cursor < text.length) out.push(text.slice(cursor));
      return out;
    };

    // Defense-in-depth (Phase 1): node_exhausted payloads carry no .question.
    // Hosts branch before rendering this view; if one regresses, render
    // nothing instead of crashing the whole surface on q.section below.
    if (!interaction || !interaction.question) return null;
    const q = interaction.question;
    const diag = interaction.diagnostic || {};
    const submitted = !!answerResult;
    const canSubmit = selectedIdx !== null && confidence !== null;

    function handleSubmit() {
      const elapsed = Math.round((Date.now() - startedAtRef.current) / 1000);
      onSubmit({ choiceIdx: selectedIdx, confidence, timeSpentSeconds: elapsed });
    }

    function choiceClass(i) {
      if (!submitted) return selectedIdx === i ? "choice selected" : "choice";
      if (i === answerResult.correct_choice_index) return "choice disabled correct";
      if (i === selectedIdx) return "choice disabled wrong";
      return "choice disabled";
    }

    return (
      <div>
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
          {q.section && <span className="pill">{q.section}</span>}
          {q.topic && <span className="pill">{q.topic}</span>}
          {q.difficulty && <span className="pill">{q.difficulty}</span>}
          {q.cognitive_level && <span className="pill">{q.cognitive_level}</span>}
          <div className="bt-tools-row" style={{ marginLeft: "auto", marginBottom: 0 }}>
            <button
              className={`bt-tool-btn${showLab ? " active" : ""}`}
              onClick={() => setShowLab(v => !v)}
              aria-label="Lab values"
            >
              <span className="bt-tool-icon">🧪</span>
              <span className="bt-tool-label">Lab Values</span>
            </button>
            <button
              className={`bt-tool-btn${showNotes ? " active" : ""}`}
              onClick={() => setShowNotes(v => !v)}
              aria-label="Notes"
            >
              <span className="bt-tool-icon">📝</span>
              <span className="bt-tool-label">Notes</span>
            </button>
            <button
              className={`bt-tool-btn${textZoom !== 1 ? " active" : ""}`}
              onClick={() => setTextZoom(z => (z === 1 ? 1.2 : z === 1.2 ? 1.4 : 1))}
              aria-label="Text size"
            >
              <span className="bt-tool-icon" style={{ display: "flex", alignItems: "baseline", gap: 2 }}>
                <span style={{ fontSize: 10 }}>A</span>
                <span style={{ fontSize: 13 }}>A</span>
                <span style={{ fontSize: 17 }}>A</span>
              </span>
              <span className="bt-tool-label">Zoom</span>
            </button>
            <button
              className={`bt-tool-btn${showHelp ? " active" : ""}`}
              onClick={() => setShowHelp(v => !v)}
              aria-label="Help"
            >
              <span className="bt-tool-icon">?</span>
              <span className="bt-tool-label">Help</span>
            </button>
          </div>
        </div>

        <div className="card">
          <div
            ref={stemRef}
            onMouseUp={captureStemHighlight}
            style={{ fontSize: 17 * textZoom, lineHeight: 1.6, marginBottom: 24 }}
          >{renderStemWithHighlights(q.stem)}</div>

          {q.choices.map((c, i) => {
            const cls = choiceClass(i);
            const isCorrect = submitted && i === answerResult.correct_choice_index;
            const isWrongPicked = submitted && i === selectedIdx && !isCorrect;
            const isStruck = !submitted && !!struck[i];
            const isUnselectedWrong = submitted && !isCorrect && i !== selectedIdx;
            const distractorText = submitted
              ? (answerResult.distractor_explanations || {})[c.label]
              : null;
            const whyNotOpen = isUnselectedWrong && !!distractorText && (revealAll || !!expandedDistractors[i]);
            // Inline Tutor-style reveal card fused under the choice: green
            // (correct answer), red (the user's wrong pick), or red "why not
            // this answer" for expanded unselected distractors. Data comes from
            // the /answer payload; render nothing when the field is absent so
            // older backends degrade gracefully.
            const inlineExpl = (() => {
              if (!submitted) return null;
              if (isCorrect && answerResult.correct_answer_explanation) {
                return { klass: "correct", tag: "Correct Answer", text: answerResult.correct_answer_explanation };
              }
              if (isWrongPicked && distractorText) {
                return { klass: "wrong", tag: `${c.label} — Why this is wrong`, text: distractorText };
              }
              if (whyNotOpen) {
                return { klass: "wrong", tag: `${c.label} — Why not this answer`, text: distractorText };
              }
              return null;
            })();
            return (
              <div key={i}>
                <div
                  className={`${cls}${isStruck ? " struck" : ""}${inlineExpl ? " has-expl" : ""}`}
                  onClick={() => !submitted && !isStruck && setSelectedIdx(i)}
                >
                  <div className="label">{c.label}</div>
                  <div className="text" style={textZoom !== 1 ? { fontSize: `${textZoom}em` } : undefined}>
                    {c.text}
                    {isCorrect && <span className="feedback-pill correct">✓ Correct</span>}
                    {isWrongPicked && <span className="feedback-pill wrong">✗ Incorrect</span>}
                  </div>
                  {!submitted && selectedIdx !== i && (
                    <button
                      className={`bt-strike-btn${isStruck ? " struck" : ""}`}
                      onClick={(e) => { e.stopPropagation(); setStruck(prev => ({ ...prev, [i]: !prev[i] })); }}
                      aria-label={isStruck ? "Remove strikethrough" : "Strike out this answer"}
                    >✕</button>
                  )}
                  {isUnselectedWrong && distractorText && (
                    <button
                      className="bt-whynot-btn"
                      onClick={(e) => {
                        e.stopPropagation();
                        if (revealAll) {
                          // Leaving reveal-all: keep the others open, close this one.
                          setRevealAll(false);
                          setExpandedDistractors(prev => {
                            const next = { ...prev };
                            q.choices.forEach((_, j) => {
                              if (j !== i && j !== selectedIdx && j !== answerResult.correct_choice_index) next[j] = true;
                            });
                            next[i] = false;
                            return next;
                          });
                        } else {
                          setExpandedDistractors(prev => ({ ...prev, [i]: !prev[i] }));
                        }
                      }}
                      aria-label={whyNotOpen ? "Hide explanation" : "Show why this is wrong"}
                    >{whyNotOpen ? "Hide" : "Why not?"}</button>
                  )}
                </div>
                {inlineExpl && (
                  <div
                    className={`bt-inline-expl ${inlineExpl.klass}`}
                    style={textZoom !== 1 ? { fontSize: `${0.9 * textZoom}em` } : undefined}
                  >
                    <div className="bt-expl-tag">{inlineExpl.tag}</div>
                    {inlineExpl.text}
                  </div>
                )}
              </div>
            );
          })}

          {/* Reveal/collapse-all toggle for unselected distractor explanations (Tutor parity) */}
          {submitted && q.choices.some((c, i) =>
            i !== answerResult.correct_choice_index && i !== selectedIdx
            && (answerResult.distractor_explanations || {})[c.label]
          ) && (
            <div style={{ display: "flex", justifyContent: "center", marginTop: 4 }}>
              <button
                className="bt-whynot-btn"
                style={{ padding: "8px 18px" }}
                onClick={() => {
                  setRevealAll(v => !v);
                  setExpandedDistractors({});
                }}
              >{revealAll ? "Collapse all explanations" : "Reveal all explanations"}</button>
            </div>
          )}

          {!submitted && (
            <>
              <label className="field" style={{ marginTop: 24 }}>How confident are you?</label>
              <div className="confidence-row">
                {[
                  { label: "Guess",   value: 1, klass: "guess" },
                  { label: "Partial", value: 3, klass: "partial" },
                  { label: "Certain", value: 5, klass: "certain" },
                ].map(opt => (
                  <div
                    key={opt.value}
                    className={`confidence-pip ${opt.klass} ${confidence === opt.value ? "selected" : ""}`}
                    onClick={() => setConfidence(opt.value)}
                  >{opt.label}</div>
                ))}
              </div>

              <div style={{ marginTop: 24, display: "flex", gap: 12 }}>
                <button className="btn-primary" onClick={handleSubmit} disabled={!canSubmit}>
                  Submit Answer
                </button>
                {endLabel && (
                  <button className="btn-secondary" onClick={onEnd}>
                    {endLabel}
                  </button>
                )}
              </div>
            </>
          )}

          {submitted && (
            <FeedbackPanel
              result={answerResult}
              keyConcepts={q.key_concepts}
              onNext={onNext}
              onEnd={onEnd}
              endLabel={endLabel}
              hideAnalysisStatus={hideAnalysisStatus}
              nextLabel={nextLabel}
              onTeachBack={onTeachBack}
              textZoom={textZoom}
              suppressExplanation={!!answerResult.correct_answer_explanation}
            />
          )}
        </div>

        {diag.targeting_rationale && (
          <div className="card dense" style={{ fontSize: 12, color: "var(--muted)", fontFamily: "'DM Mono', monospace" }}>
            <strong>diagnostic:</strong> {diag.targeting_rationale}
            {diag.primary_concept_targeted && <> · primary concept: <em>{diag.primary_concept_targeted}</em></>}
          </div>
        )}

        {/* Tool modals — draggable (by header), closable, portaled above the
            Live Mastery rail so nothing obscures or blocks them. */}
        {showLab && (
          <BtToolModal title="Normal Lab Values" onClose={() => setShowLab(false)}>
            <div className="bt-lab-table">
              <div className="bt-lab-group">Hematology</div>
              Hgb: 14–18 g/dL (M), 12–16 g/dL (F)<br />
              Hct: 42–52% (M), 37–47% (F)<br />
              WBC: 4.5–11 × 10³/μL<br />
              Platelets: 150–400 × 10³/μL<br />
              <div className="bt-lab-group">Chemistry</div>
              Na: 136–145 mEq/L<br />
              K: 3.5–5.0 mEq/L<br />
              Cl: 98–106 mEq/L<br />
              Glucose: 70–100 mg/dL<br />
              BUN: 7–20 mg/dL<br />
              Creatinine: 0.7–1.3 mg/dL<br />
            </div>
          </BtToolModal>
        )}

        {showNotes && (
          <BtToolModal title="Notes" className="notes" onClose={() => setShowNotes(false)}>
            <textarea
              className="bt-notes-textarea"
              value={notes[q.content_hash] || ""}
              onChange={(e) => {
                const hash = q.content_hash;
                setNotes(prev => ({ ...prev, [hash]: e.target.value }));
              }}
              placeholder="Type your notes for this question…"
              aria-label="Note text area"
            />
          </BtToolModal>
        )}

        {showHelp && (
          <BtToolModal title="How This Works" onClose={() => setShowHelp(false)}>
            <ul className="bt-help-list">
              <li>Pick an answer, rate your confidence, then <strong>Submit</strong>. Confidence shapes how your mastery updates — rate honestly.</li>
              <li>Use the <strong>✕</strong> on a choice to strike out answers you've eliminated.</li>
              <li>After submitting, read the explanation cards under the choices, then continue with <strong>Next Question</strong>.</li>
              <li><strong>🧪 Lab Values</strong> — reference table of normal values.</li>
              <li><strong>📝 Notes</strong> — a per-question scratchpad, kept for this session.</li>
              <li><strong>Aa Zoom</strong> — cycle text size (100% → 120% → 140%).</li>
              <li>Drag any tool window by its title bar; close it with ✕.</li>
            </ul>
          </BtToolModal>
        )}
      </div>
    );
  }

  // ─────────── Feedback panel ───────────
  function FeedbackPanel({ result, keyConcepts, onNext, onEnd, endLabel, hideAnalysisStatus = false, nextLabel = "Next Question →", onTeachBack, textZoom = 1, suppressExplanation = false }) {
    const showTeachBack = !!result.teach_back_required && !!onTeachBack;
    return (
      <div style={{ marginTop: 24 }}>
        <div style={{ marginBottom: 16 }}>
          {result.is_correct
            ? <span className="badge green">Correct</span>
            : <span className="badge red">Incorrect</span>}
          {!hideAnalysisStatus && <>
            {" "}
            <span
              className="badge muted"
              style={{
                opacity: result.analysis?.status === "pending" ? 0.55 : 1,
                transition: "opacity 300ms var(--easing)",
              }}
            >
              {result.analysis?.status === "pending"
                ? "analyzing…"
                : (result.analysis?.category || "answered")}
            </span>
          </>}
          {!result.is_correct && result.correct_choice_label && (
            <span style={{ marginLeft: 12, fontSize: 13, color: "var(--muted)" }}>
              correct answer: <strong>{result.correct_choice_label}</strong>
            </span>
          )}
        </div>

        {/* Narrative explanation — suppressed when the per-choice inline cards
            already carry the same content (Tutor parity: no duplication). */}
        {result.explanation && !suppressExplanation && (
          <div style={{ fontSize: 15 * textZoom, lineHeight: 1.65, marginBottom: 20, whiteSpace: "pre-wrap" }}>
            {result.explanation}
          </div>
        )}

        {result.learning_objective && (
          <div style={{ marginBottom: 16 }}>
            <div className="eyebrow" style={{ marginBottom: 6 }}>Learning objective</div>
            <div style={{ fontSize: 14 * textZoom }}>{result.learning_objective}</div>
          </div>
        )}

        {Array.isArray(result.references) && result.references.length > 0 && (
          <div style={{ marginBottom: 16 }}>
            <div className="eyebrow" style={{ marginBottom: 6 }}>References</div>
            {result.references.map((r, i) => (
              <div
                key={i}
                style={{
                  fontSize: 11 * textZoom, fontFamily: "'DM Mono', monospace",
                  color: "var(--muted)", lineHeight: 1.5, marginBottom: 6,
                }}
              >{typeof r === "string" ? r : (r && (r.citation || r.text)) || ""}</div>
            ))}
          </div>
        )}

        {result.state_delta?.concepts_changed?.length > 0 && (
          <div className="concept-panel">
            <h4>Concept tracker · {result.state_delta.concepts_changed.length} updated</h4>
            {result.state_delta.concepts_changed.map((c, i) => (
              <div className="concept-row" key={i}>
                <span className="name">{c.concept}</span>
                <span className="stats">{c.correct}/{c.attempts} correct</span>
              </div>
            ))}
          </div>
        )}

        {showTeachBack && (
          <div className="teachback-invite">
            <div className="teachback-invite-eyebrow">Verify your understanding</div>
            <div className="teachback-invite-headline">
              {result.is_correct
                ? "Quick teach-back to confirm this isn't a confident guess."
                : "You answered with high confidence. Let's pin down the misconception."}
            </div>
            {result.teach_back_node_title && (
              <div className="teachback-invite-detail">
                Topic: <em>{result.teach_back_node_title}</em>
              </div>
            )}
            <div style={{ marginTop: 10 }}>
              <button className="btn-primary" onClick={onTeachBack}>
                Begin teach-back →
              </button>
            </div>
          </div>
        )}

        <div style={{ marginTop: 24, display: "flex", gap: 12 }}>
          <button className="btn-primary" onClick={onNext}>{nextLabel}</button>
          {endLabel && (
            <button className="btn-secondary" onClick={onEnd}>{endLabel}</button>
          )}
        </div>
      </div>
    );
  }

  // ─────────── Teach-back modal (Day 5) ───────────
  // Triggered from FeedbackPanel after a wrong+Certain or right+Certain answer
  // when the affected vault node's mastery is ≥ TEACH_BACK_MASTERY_FLOOR (0.40).
  // Free-text → /session/{id}/teachback → 4-criterion rubric card.
  function TeachBackModal({ context, result, busy, error, onSubmit, onClose }) {
    const [responseText, setResponseText] = useState("");
    const ready = responseText.trim().length >= 20 && !busy && !result;
    const wordCount = responseText.trim().split(/\s+/).filter(Boolean).length;

    const verdictColor = (v) =>
      v === "expert" ? "var(--accent)" :
      v === "competent" ? "#7A8C5C" :
      "var(--red)";

    return (
      <div className="teachback-modal" onClick={(e) => e.target === e.currentTarget && onClose()}>
        <div className="teachback-card">
          <div className="teachback-header">
            <div>
              <div className="eyebrow">Teach-back</div>
              <h2 className="teachback-title">
                {context?.nodeTitle || "Verify your understanding"}
              </h2>
            </div>
            <button className="teachback-close" onClick={onClose} aria-label="Close">×</button>
          </div>

          {!result && !error?.kind && (
            <>
              {context?.prompt && (
                <p className="teachback-prompt">{context.prompt}</p>
              )}
              <textarea
                className="teachback-textarea"
                placeholder="Explain in your own words — mechanism, named structures, when it changes management. Aim for 60–150 words. Specific beats verbose."
                value={responseText}
                onChange={(e) => setResponseText(e.target.value)}
                disabled={busy}
                rows={8}
                autoFocus
              />
              <div className="teachback-meta">
                <span>{wordCount} word{wordCount === 1 ? "" : "s"}</span>
                <span style={{ color: responseText.trim().length >= 20 ? "var(--accent)" : "var(--muted)" }}>
                  {responseText.trim().length >= 20 ? "ready" : `≥20 chars to submit (${responseText.trim().length})`}
                </span>
              </div>
              <div className="teachback-actions">
                <button className="btn-primary" onClick={() => onSubmit(responseText)} disabled={!ready}>
                  {busy ? "Grading…" : "Submit teach-back"}
                </button>
                <button className="btn-secondary" onClick={onClose} disabled={busy}>
                  Skip for now
                </button>
              </div>
            </>
          )}

          {error?.kind === "expertise_reversal" && (
            <div className="teachback-suppressed">
              <div className="teachback-suppressed-headline">Teach-back suppressed</div>
              <p style={{ marginTop: 10, lineHeight: 1.55, color: "var(--muted)" }}>
                {error.message}
              </p>
              <p style={{ marginTop: 10, fontSize: 13, color: "var(--muted)" }}>
                Current mastery: <strong>{(error.currentMastery ?? 0).toFixed(2)}</strong>
                {" · floor: "}<strong>{(error.floor ?? 0.4).toFixed(2)}</strong>
              </p>
              <div style={{ marginTop: 18 }}>
                <button className="btn-primary" onClick={onClose}>Got it — back to studying</button>
              </div>
            </div>
          )}

          {error?.kind === "generic" && !result && (
            <div className="teachback-error">
              <div style={{ color: "var(--red)", marginBottom: 10 }}>{error.message}</div>
              <button className="btn-secondary" onClick={onClose}>Close</button>
            </div>
          )}

          {result && (
            <div className="teachback-result">
              <div className="teachback-verdict" style={{ color: verdictColor(result.verdict) }}>
                Verdict: {result.verdict}
              </div>
              {result.feedback_to_learner && (
                <div className="teachback-feedback">{result.feedback_to_learner}</div>
              )}
              <div className="teachback-rubric">
                {result.rubric && Object.entries(result.rubric).map(([dim, crit]) => (
                  <div className="teachback-rubric-row" key={dim}>
                    <div className="teachback-rubric-dim">{dim}</div>
                    <div className="teachback-rubric-score">
                      <span className={`rubric-pip s${crit.score}`}>{crit.score}/3</span>
                    </div>
                    <div className="teachback-rubric-note">{crit.note}</div>
                  </div>
                ))}
              </div>
              {result.concept_evidence?.length > 0 && (
                <div className="teachback-concept-evidence">
                  <div className="eyebrow" style={{ marginBottom: 6 }}>Mastery nudges</div>
                  {result.concept_evidence.map((ce, i) => (
                    <div key={i} className="teachback-concept-row">
                      <span>{ce.concept}</span>
                      <span style={{
                        color: ce.mastery_nudge >= 0 ? "var(--accent)" : "var(--red)",
                        fontFamily: "'DM Mono', monospace",
                      }}>
                        {ce.mastery_nudge >= 0 ? "+" : ""}{ce.mastery_nudge.toFixed(2)}
                      </span>
                    </div>
                  ))}
                </div>
              )}
              <div style={{ marginTop: 24 }}>
                <button className="btn-primary" onClick={onClose}>Continue →</button>
              </div>
            </div>
          )}
        </div>
      </div>
    );
  }

  // [PWA port] Export instead of self-mounting; the PWA renders <BrainTraceApp/>
  // inside the Brain Trace tab overlay.
  window.BrainTraceApp = App;
  
})();
