// Luminara — «Мои исследования» (Research) page.
// Hierarchy (agreed with Eugene/Ruslan, 16–17 Jun):
//   Telegram (ecosystem) → Тема (1–10 from PDF) → Подтема (= authored "scenes")
//   → inside a subtopic: TEXT (left, primary) + MEDIA links by category (right)
//     + an INSIGHTS field at the end of the text.
// Read-tracking: a subtopic is marked read when the reader scrolls to the bottom
//   AND dwells briefly (not just a fast flick). Marked subtopics persist to
//   lesson_progress and feed the personal "My Universe" map (B2).
//
// Data source: window.LUMINARA_DATA (ECOSYSTEMS / INSIGHTS / EXTERNAL / BLOCK_TOPICS),
//   produced by content-graph.js from the #luminara-content slot (10 authored themes).
const { useState: useState_r, useEffect: useEffect_r, useRef: useRef_r } = React;

// The ecosystem that owns the authored themes. Telegram is the parent container;
// every authored topic is a "theme" inside it (TON · Telegram is the general one).
const RESEARCH_ECO = {
  id: "telegram",
  name: "Telegram",
  kicker: { en: "Ecosystem", ru: "Экосистема", uk: "Екосистема", kk: "Экожүйе", uz: "Ekotizim", es: "Ecosistema", fr: "Écosystème", hy: "Էկոհամակարգ" },
  blurb: {
    en: "Telegram's economic layer: one messenger holding a wallet, apps, communities, tokens and payments. The themes below unpack it piece by piece.",
    ru: "Экономический слой Telegram: один мессенджер, внутри которого живут кошелёк, приложения, сообщества, жетоны и платежи. Темы ниже раскрывают его по частям.",
    uk: "Економічний шар Telegram: один месенджер, усередині якого живуть гаманець, застосунки, спільноти, жетони й платежі. Теми нижче розкривають його частинами.",
    kk: "Telegram-ның экономикалық қабаты: бір мессенджер ішінде әмиян, қосымшалар, қауымдастықтар, жетондар мен төлемдер. Төмендегі тақырыптар оны бөлшектеп ашады.",
    uz: "Telegramning iqtisodiy qatlami: bitta messenjer ichida hamyon, ilovalar, hamjamiyatlar, jetonlar va to'lovlar. Quyidagi mavzular uni bo'lakma-bo'lak ochadi.", es: "La capa económica de Telegram: un mensajero que contiene billetera, apps, comunidades, tokens y pagos. Los temas de abajo lo desglosan pieza por pieza.", fr: "La couche économique de Telegram : une messagerie qui réunit portefeuille, applis, communautés, tokens et paiements. Les thèmes ci-dessous la décortiquent pièce par pièce.", hy: "Telegram-ի տնտեսական շերտը՝ մեկ մեսենջեր՝ դրամապանակով, հավելվածներով, համայնքներով, թոքեններով և վճարումներով։ Ստորև թեմաները բացում են այն մաս առ մաս։" },
};

// Course families that live in Research but are not Telegram themes must keep
// their own identity in the reader header.  The catalogue topic id is the
// stable source here: chapter topics intentionally use `<course>-chNN`, while
// their localized human title continues to come from Directus.
const researchOwnerName = (topic) => {
  if (/^base-ch\d{2}$/.test(topic || "")) return "Base";
  if (/^bitcoin-atlas(?:-ch\d{2})?$/.test(topic || "")) return "Bitcoin";
  return RESEARCH_ECO.name;
};

// The general (overview) theme shown first, full-width.
const GENERAL_TOPIC = "ton";
// RESEARCH-HIDE-CARDS: topics temporarily hidden from the research card grid (front-only,
// reversible). Ethereum lives as the lessons branch (eth_01..14) — its 5-scene research
// overview is a concept-overlap we don't want surfaced here; GameFi hidden per product
// decision. Source data in Directus is untouched — remove an id here to bring the card back.
const RESEARCH_HIDE = ["ethereum", "gamefi"];

// Every CMS field is untrusted at the rendering boundary: old imports can contain
// a plain string, a locale map, or malformed JSON. Return text only. This is the
// last line of defence against React #31 ("Objects are not valid as a React child")
// turning a course route into the global white error page.
// Shared policy is loaded by v62.html before this file.
const LR = (typeof window !== "undefined" && window.LUMINARA_LOCALE) || null;
const L = (o, locale) => {
  if (typeof o === "string") return o;
  if (!o || typeof o !== "object" || Array.isArray(o)) return "";
  const _lr = (typeof window !== "undefined" && window.LUMINARA_LOCALE) || null;
  if (_lr) return _lr.resolveString(o, locale);
  for (const key of [locale, "en", "ru"]) if (typeof o[key] === "string") return o[key];
  return "";
};
const resolveField = (o, locale) => {
  if (LR) return LR.resolveLocale(o, locale);
  if (typeof o === "string") return { value: o, locale, fallback: false };
  if (o && typeof o === "object" && !Array.isArray(o)) {
    if (typeof o[locale] === "string" && o[locale].trim()) return { value: o[locale], locale, fallback: false };
    for (const key of ["en", "ru"]) if (typeof o[key] === "string" && o[key].trim()) return { value: o[key], locale: key, fallback: true };
  }
  return { value: "", locale: "", fallback: false };
};
const fieldNotice = (resolution, locale) => (LR ? LR.fallbackNotice(resolution, locale) : "");
// Directus topics normally provide ecosystem.title as a string, but older
// inline payloads may still provide {ru,en,…}. Resolve both forms before
// rendering: React cannot render the object form directly.
const titleTextR = (value, locale) => (typeof value === "string" ? value : L(value, locale));

// RESEARCH-LEVEL-AWARE: research scene bodies may arrive leveled ({simple|extended|deep|academic:{ml}})
// like Foundations. research.jsx has no level switcher (TON etc. stay at "extended"), but must still
// resolve a leveled body to a string — otherwise L() reads body[locale]=undefined and the long-read
// renders empty ("TON не лонгрид"). resolveBody: leveled → extended (with fallbacks); else flat ml/string.
const DIFF_LEVELS_R = ["simple", "extended", "deep", "academic"];
function bodyIsLeveledR(b) { return !!(b && typeof b === "object" && DIFF_LEVELS_R.some((k) => b[k] != null)); }
function levelStrR(o, loc) { if (o == null) return ""; return (typeof o === "string") ? o : L(o, loc); }
function resolveBodyField(b, loc) {
  if (b == null) return { value: "", locale: "", fallback: false };
  if (bodyIsLeveledR(b)) {
    for (const lv of ["extended", "simple", "deep", "academic"]) {
      if (b[lv] != null) {
        const r = resolveField(b[lv], loc);
        if (r.value) return r;
      }
    }
    return { value: "", locale: "", fallback: false };
  }
  return resolveField(b, loc);
}
function resolveBody(b, loc) { return resolveBodyField(b, loc).value; }

// SOURCES (#63): every template resolves titles and links through ONE shared, security-hardened
// model (v62/sources.js). Only absolute http(s) URLs may ever become an href — `javascript:`,
// `data:`, protocol-relative and unparseable values degrade to plain bibliography text. A source
// title may be a plain string or a multilingual object; the raw object must never reach React
// (error #31 → white screen). These thin wrappers keep the historic call sites working.
const SRC = () => (typeof window !== "undefined" && window.LuminaraSources) || null;
const srcTitle = (s, locale) => {
  const M = SRC();
  if (M) return M.sourceTitle(s, locale);
  const t = s && s.title;
  return (typeof t === "string" ? t : (t ? (t[locale] || t.en || t.ru || "") : "")) || "";
};

// A source with an allowlisted url → external link (↗); a book (no usable url) → a Google Books
// search by its ENGLISH title (📖, region-neutral); nothing usable → no link (plain text).
function srcHref(s, locale) {
  const M = SRC();
  return M ? M.sourceLink(s, locale) : null;
}

// ── i18n strings local to this page ──
const RUI = {
  catalog:   { en: "Topic catalog", ru: "Каталог тем", uk: "Каталог тем", kk: "Тақырыптар каталогы", uz: "Mavzular katalogi", es: "Catálogo de temas", fr: "Catalogue des thèmes", hy: "Թեմաների կատալոգ" },
  title:     { en: "My Research", ru: "Мои исследования", uk: "Мої дослідження", kk: "Менің зерттеулерім", uz: "Mening tadqiqotlarim", es: "Mis investigaciones", fr: "Mes recherches", hy: "Իմ հետազոտությունները" },
  lede:      { en: "Choose a learning area: Foundations, Ecosystems or Industries.",
               ru: "Выберите направление обучения: Основания, Экосистемы или Индустрии.",
               uk: "Оберіть напрям навчання: Основи, Екосистеми або Індустрії.",
               kk: "Оқу бағытын таңдаңыз: Негіздер, Экожүйелер немесе Индустриялар.",
               uz: "Taʼlim yoʻnalishini tanlang: Asoslar, Ekotizimlar yoki Industriyalar.",
               es: "Elige un área de aprendizaje: Fundamentos, Ecosistemas o Industrias.",
               fr: "Choisissez un domaine : Fondamentaux, Écosystèmes ou Industries.",
               hy: "Ընտրեք ուսուցման ուղղությունը՝ Հիմունքներ, Էկոհամակարգեր կամ Ոլորտներ։" },
  ecosystem: { en: "Ecosystem", ru: "Экосистема", uk: "Екосистема", kk: "Экожүйе", uz: "Ekotizim", es: "Ecosistema", fr: "Écosystème", hy: "Էկոհամակարգ" },
  general:   { en: "General theme", ru: "Общая тема", uk: "Загальна тема", kk: "Жалпы тақырып", uz: "Umumiy mavzu", es: "Tema general", fr: "Thème général", hy: "Ընդհանուր թեմա" },
  themesH:   { en: "Themes", ru: "Темы", uk: "Теми", kk: "Тақырыптар", uz: "Mavzular", es: "Temas", fr: "Thèmes", hy: "Թեմաներ" },
  theme:     { en: "Theme", ru: "Тема", uk: "Тема", kk: "Тақырып", uz: "Mavzu", es: "Tema", fr: "Thème", hy: "Թեմա" },
  chapter:   { en: "Chapter", ru: "Глава", uk: "Розділ", kk: "Тарау", uz: "Bob", es: "Capítulo", fr: "Chapitre", hy: "Գլուխ" },
  courseChapters:  { en: "chapters", ru: "глав", uk: "розділів", kk: "тарау", uz: "bob", es: "capítulos", fr: "chapitres", hy: "գլուխ" },
  open:      { en: "Open theme", ru: "Открыть тему", uk: "Відкрити тему", kk: "Тақырыпты ашу", uz: "Mavzuni ochish", es: "Abrir tema", fr: "Ouvrir le thème", hy: "Բացել թեման" },
  chapters:  { en: "subtopics", ru: "подтем", uk: "підтем", kk: "ішкі тақырып", uz: "ichki mavzu", es: "subtemas", fr: "sous-thèmes", hy: "ենթաթեմաներ" },
  backAll:   { en: "All research", ru: "Все исследования", uk: "Усі дослідження", kk: "Барлық зерттеулер", uz: "Barcha tadqiqotlar", es: "Todas las investigaciones", fr: "Toutes les recherches", hy: "Բոլոր հետազոտությունները" },
  backTheme: { en: "Back to theme", ru: "Назад к теме", uk: "Назад до теми", kk: "Тақырыпқа қайту", uz: "Mavzuga qaytish", es: "Volver al tema", fr: "Retour au thème", hy: "Վերադառնալ թեմային" },
  loadingTopic: { en: "Loading course content…", ru: "Загружаем содержание курса…", uk: "Завантажуємо зміст курсу…", kk: "Курс мазмұны жүктелуде…", uz: "Kurs mazmuni yuklanmoqda…", es: "Cargando el contenido del curso…", fr: "Chargement du contenu du cours…", hy: "Դասընթացի բովանդակությունը բեռնվում է…" },
  unavailableTopic: { en: "This course is temporarily unavailable. Please return to the catalog and try again.", ru: "Этот курс временно недоступен. Вернитесь в каталог и попробуйте ещё раз.", uk: "Цей курс тимчасово недоступний. Поверніться до каталогу та спробуйте ще раз.", kk: "Бұл курс уақытша қолжетімсіз. Каталогқа оралып, қайта көріңіз.", uz: "Bu kurs vaqtincha mavjud emas. Katalogga qaytib, yana urinib koʻring.", es: "Este curso no está disponible temporalmente. Vuelve al catálogo e inténtalo de nuevo.", fr: "Ce cours est temporairement indisponible. Revenez au catalogue et réessayez.", hy: "Այս դասընթացը ժամանակավորապես անհասանելի է։ Վերադարձեք կատալոգ և փորձեք կրկին։" },
  subtopics: { en: "Subtopics", ru: "Подтемы", uk: "Підтеми", kk: "Ішкі тақырыптар", uz: "Ichki mavzular", es: "Subtemas", fr: "Sous-thèmes", hy: "Ենթաթեմաներ" },
  media:     { en: "Media", ru: "Медиа", uk: "Медіа", kk: "Медиа", uz: "Media", es: "Multimedia", fr: "Médias", hy: "Մեդիա" },
  share:     { en: "Share", ru: "Поделиться", uk: "Поділитися", kk: "Бөлісу", uz: "Ulashish",
               es: "Compartir", fr: "Partager", hy: "Կիսվել" },
  copied:    { en: "Link copied", ru: "Ссылка скопирована", uk: "Посилання скопійовано", kk: "Сілтеме көшірілді", uz: "Havola nusxalandi",
               es: "Enlace copiado", fr: "Lien copié", hy: "Հղումը պատճենվեց" },
  video:     { en: "Video", ru: "Видео", uk: "Відео", kk: "Бейне", uz: "Video", es: "Video", fr: "Vidéo", hy: "Տեսանյութ" },
  audio:     { en: "Audio", ru: "Аудио", uk: "Аудіо", kk: "Аудио", uz: "Audio", es: "Audio", fr: "Audio", hy: "Աուդիո" },
  files:     { en: "Materials", ru: "Материалы", uk: "Матеріали", kk: "Материалдар", uz: "Materiallar", es: "Materiales", fr: "Documents", hy: "Նյութեր" },
  soon:      { en: "soon", ru: "скоро", uk: "незабаром", kk: "жақында", uz: "tez orada", es: "pronto", fr: "bientôt", hy: "շուտով" },
  read:      { en: "Read", ru: "Прочитано", uk: "Прочитано", kk: "Оқылды", uz: "O'qildi", es: "Leído", fr: "Lu", hy: "Կարդացված" },
  insightsH: { en: "Your insight", ru: "Ваш инсайт", uk: "Ваш інсайт", kk: "Сіздің инсайт", uz: "Sizning insight", es: "Tu idea", fr: "Ton idée", hy: "Քո գաղափարը" },
  insightPh: { en: "Write what struck you in this subtopic…", ru: "Напишите, что вас зацепило в этой подтеме…", uk: "Напишіть, що вас зачепило в цій підтемі…", kk: "Осы ішкі тақырыпта сізді не қызықтырғанын жазыңыз…", uz: "Bu ichki mavzuda sizni nima qiziqtirganini yozing…", es: "Escribe lo que te impactó en este subtema…", fr: "Écris ce qui t'a marqué dans ce sous-thème…", hy: "Գրիր, ինչը քեզ տպավորեց այս ենթաթեմայում…" },
  insightSave:{ en: "Save to journal", ru: "Сохранить в журнал", uk: "Зберегти в журнал", kk: "Журналға сақтау", uz: "Kundalikka saqlash", es: "Guardar en el diario", fr: "Sauvegarder dans le journal", hy: "Պահել օրագրում" },
  insightSaved:{ en: "Saved ✓", ru: "Сохранено ✓", uk: "Збережено ✓", kk: "Сақталды ✓", uz: "Saqlandi ✓", es: "Guardado ✓", fr: "Sauvegardé ✓", hy: "Պահված է ✓" },
  special:  { en: "Special course", ru: "Особый курс", uk: "Особливий курс", kk: "Арнайы курс", uz: "Maxsus kurs", es: "Curso especial", fr: "Cours spécial", hy: "Հատուկ դասընթաց" },
  course:   { en: "Course", ru: "Курс", uk: "Курс", kk: "Курс", uz: "Kurs", es: "Curso", fr: "Cours", hy: "Դասընթաց" },
  bonus:    { en: "Bonus", ru: "Бонус", uk: "Бонус", kk: "Бонус", uz: "Bonus", es: "Bono", fr: "Bonus", hy: "Բոնուս" },
  modules:  { en: "modules", ru: "модулей", uk: "модулів", kk: "модуль", uz: "modul", es: "módulos", fr: "modules", hy: "մոդուլ" },
  perWeek:  { en: "one a week", ru: "по одному в неделю", uk: "по одному на тиждень", kk: "аптасына біреу", uz: "haftasiga bitta", es: "uno por semana", fr: "un par semaine", hy: "շաբաթը մեկ" },
  openCourse:{ en: "Open course", ru: "Открыть курс", uk: "Відкрити курс", kk: "Курсты ашу", uz: "Kursni ochish", es: "Abrir curso", fr: "Ouvrir le cours", hy: "Բացել դասընթացը" },
  module:   { en: "Module", ru: "Модуль", uk: "Модуль", kk: "Модуль", uz: "Modul", es: "Módulo", fr: "Module", hy: "Մոդուլ" },
  wpRu:     { en: "Intro (RU)", ru: "Подводка", uk: "Підводка", kk: "Кіріспе", uz: "Kirish", es: "Introducción (RU)", fr: "Intro (RU)", hy: "Ներածություն (RU)" },
  wpEn:     { en: "Original (EN)", ru: "Оригинал (EN)", uk: "Оригінал (EN)", kk: "Түпнұсқа (EN)", uz: "Original (EN)", es: "Original (EN)", fr: "Original (EN)", hy: "Բնագիր (EN)" },
  wpTr:     { en: "Translation", ru: "Перевод", uk: "Переклад", kk: "Аударма", uz: "Tarjima", es: "Traducción", fr: "Traduction", hy: "Թարգմանություն" },
  wpIn:     { en: "Interpretation", ru: "Толкование", uk: "Тлумачення", kk: "Түсіндірме", uz: "Talqin", es: "Interpretación", fr: "Interprétation", hy: "Մեկնաբանություն" },
  wpStatus: { en: "By 2026", ru: "Что реализовано к 2026", uk: "Що реалізовано до 2026", kk: "2026 жылға қарай", uz: "2026 yilga kelib", es: "Para 2026", fr: "D'ici 2026", hy: "Մինչև 2026" },
  wpQ:      { en: "For discussion", ru: "Вопрос для обсуждения", uk: "Питання для обговорення", kk: "Талқылауға", uz: "Muhokama uchun", es: "Para debatir", fr: "À débattre", hy: "Քննարկման համար" },
  wpEmpty:  { en: "This module is being prepared.", ru: "Этот модуль готовится.", uk: "Цей модуль готується.", kk: "Бұл модуль дайындалуда.", uz: "Bu modul tayyorlanmoqda.", es: "Este módulo está en preparación.", fr: "Ce module est en préparation.", hy: "Այս մոդուլը պատրաստվում է։" },
  backCourse:{ en: "All modules", ru: "Все модули", uk: "Усі модулі", kk: "Барлық модульдер", uz: "Barcha modullar", es: "Todos los módulos", fr: "Tous les modules", hy: "Բոլոր մոդուլները" },
  wpContext:{ en: "Context 2026", ru: "Контекст 2026", uk: "Контекст 2026", kk: "2026 контексі", uz: "2026 konteksti", es: "Contexto 2026", fr: "Contexte 2026", hy: "Համատեքստ 2026" },
  wpView:   { en: "View from 2026", ru: "Взгляд из 2026", uk: "Погляд із 2026", kk: "2026 көзқарасы", uz: "2026 nuqtai nazari", es: "Visión desde 2026", fr: "Regard depuis 2026", hy: "Հայացք 2026-ից" },
  wpSources:{ en: "Sources", ru: "Источники", uk: "Джерела", kk: "Дереккөздер", uz: "Manbalar", es: "Fuentes", fr: "Sources", hy: "Աղբյուրներ" },
  wpLinks:  { en: "Atlas links", ru: "Связи в Атласе", uk: "Зв'язки в Атласі", kk: "Атластағы байланыстар", uz: "Atlasdagi bogʻlanishlar", es: "Enlaces del atlas", fr: "Liens de l'atlas", hy: "Ատլասի հղումներ" },
};

// Directus can expose a course chapter under its stable machine key (for
// example, "base-ch01"). That key is useful for routing, but must never be
// presented as the reader-facing title. A course overview must preserve its
// course identity; it must not borrow the title of its first child lesson.
const isMachineCourseKeyR = (value) =>
  typeof value === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*-ch\d{2}$/i.test(value.trim());

const machineCourseFallbackR = (topic, locale) => {
  const match = String(topic || "").match(/-ch(\d{2})$/i);
  return match ? `${L(RUI.chapter, locale)} ${Number(match[1])}` : String(topic || "");
};

const courseTitleFallbackR = (topic, locale) => {
  if (/^base-ch\d{2}$/i.test(topic || "")) return "Base";
  if (/^bitcoin-atlas(?:-ch\d{2})?$/i.test(topic || "")) return "Bitcoin";
  return machineCourseFallbackR(topic, locale);
};

const researchTopicTitleR = (eco, topic, locale) => {
  const title = titleTextR(eco?.titleMl, locale) || titleTextR(eco?.title, locale);
  if (title && !isMachineCourseKeyR(title)) return title;

  return isMachineCourseKeyR(title || topic)
    ? courseTitleFallbackR(topic, locale)
    : title || topic;
};

// A bad/deep link or a briefly unavailable CMS response must stay inside the
// Research surface. It must never bubble into LumViewBoundary and look like a
// site-wide outage. The next content refresh re-renders this component normally.
function ResearchRouteFallback({ locale, onBack, loading = false }) {
  return (
    <div className="section-pad fade-in research" data-c="unavailable">
      <button className="eco-back" onClick={onBack}><span>←</span> {L(RUI.backAll, locale)}</button>
      <div className="rs-thead">
        <div className="rs-thead-eco">{L(RUI.catalog, locale)}</div>
        <h2>{loading ? L(RUI.loadingTopic, locale) : L(RUI.unavailableTopic, locale)}</h2>
        {!loading && <p>{L(RUI.unavailableTopic, locale)}</p>}
      </div>
    </div>
  );
}

// Read-tracking store (client mirror; server is source of truth via lesson_progress).
// Key = `${topic}:${subIdx}`. We keep a per-session Set and also push to the API.
function useReadState(api, demo) {
  const [readSet, setReadSet] = useState_r(() => new Set());
  const [progressData, setProgressData] = useState_r({ byTopic: {}, quizByTopic: {}, quizSummary: { attempted: 0, correct: 0, score_pct: null } });
  // hydrate from server once
  useEffect_r(() => {
    if (demo || !api) return;
    let alive = true;
    (async () => {
      try {
        const p = await api.progress.get();           // { byTopic: { topic: {scene_idx, completed} } }
        if (!alive || !p || !p.byTopic) return;
        setProgressData(p);
        const s = new Set();
        Object.entries(p.byTopic).forEach(([topic, v]) => {
          if (!v || !Array.isArray(v.completed_scenes)) return;
          v.completed_scenes.forEach((row) => {
            if (row && row.scene_key) s.add(`${topic}:ck:${row.scene_key}`);
            if (row && Number.isInteger(row.scene_idx)) s.add(`${topic}:${row.scene_idx}`);
          });
        });
        setReadSet(s);
      } catch (e) { /* start empty */ }
    })();
    return () => { alive = false; };
  }, []);
  const markRead = (topic, idx, scenes) => {
    const total = Array.isArray(scenes) ? scenes.length : 0;
    const scene = Array.isArray(scenes) ? scenes[idx] : null;
    const sceneKey = window.LuminaraProgress.sceneKey(topic, scene, idx);
    setReadSet(prev => {
      if (prev.has(`${topic}:ck:${sceneKey}`)) return prev;
      const next = new Set(prev);
      next.add(`${topic}:${idx}`);
      next.add(`${topic}:ck:${sceneKey}`);
      return next;
    });
    if (!demo && api) {
      api.progress.set(topic, idx, total > 0 && idx >= total - 1, {
        sceneKey, totalScenes: total,
      }).then(() => {
        setProgressData(prev => {
          const previous = prev.byTopic && prev.byTopic[topic];
          if (previous && previous.completion_mode === "legacy" && previous.completed) {
            return prev;
          }
          const completedScenes = Array.isArray(previous && previous.completed_scenes)
            ? previous.completed_scenes.filter((row) => row && row.scene_key !== sceneKey) : [];
          completedScenes.push({ scene_key: sceneKey, scene_idx: idx });
          return { ...prev, byTopic: { ...(prev.byTopic || {}), [topic]: {
            scene_idx: Math.max(idx, previous && Number.isInteger(previous.scene_idx) ? previous.scene_idx : 0),
            completed: total > 0 && completedScenes.length >= total,
            completion_mode: "exact",
            completed_scenes: completedScenes,
          } } };
        });
        try { window.dispatchEvent(new CustomEvent("lum:progress-changed")); } catch (e) {}
      }).catch(() => {});
    }
  };
  return { readSet, markRead, progressData };
}

function researchGroupForTopic(topic) {
  const key = String(topic || "").trim();
  if (/^(?:ton|ethereum|ethereum-whitepaper|bitcoin|bitcoin-atlas|base|base-ch\d{2})$/i.test(key)) return "ecosystems";
  if (/^(?:ai|trading|rwa|gamefi)$/i.test(key)) return "industries";
  return "foundations";
}

function Research({ t, locale, onAtlas, navEpoch, isGuest, authReady, authRevision, catalog, onOpenGroup }) {
  const D = window.LUMINARA_DATA || {};
  const api = (typeof window !== "undefined") ? window.LUMINARA_API : null;
  const demo = (typeof window !== "undefined" && window.LUMINARA_DEMO !== false);
  const { readSet, markRead } = useReadState(api, demo);

  const [openTopic, setOpenTopic] = useState_r(() =>
    (typeof window !== "undefined" && window.LUM_ROUTE) ? window.LUM_ROUTE.get("t") : null);   // topic id
  const [openSub, setOpenSub] = useState_r(() => {
    // SCENE-URL: a scene has its own unique URL (#/research?t=<topic>&s=<index>).
    if (typeof window === "undefined" || !window.LUM_ROUTE) return null;
    const s = window.LUM_ROUTE.get("s");
    return (s != null && s !== "" && !isNaN(parseInt(s, 10))) ? parseInt(s, 10) : null;
  });       // subtopic index within topic
  const [openWP, setOpenWP] = useState_r(() =>
    (typeof window !== "undefined" && window.LUM_ROUTE) ? window.LUM_ROUTE.get("wp") === "1" : false);  // white-paper course open?

  // Old Base links could carry a scene index from the former `base → base-ch01`
  // redirect. The course overview has no scene of its own, so discard that stale
  // substate and keep the durable parent URL.
  useEffect_r(() => {
    if (openTopic === "base" && openSub !== null) setOpenSub(null);
  }, [openTopic, openSub]);

  // RAIL-COLLAPSE-RESEARCH: while reading a topic/subtopic/WP the right rail (320px) is empty, so drop
  // it and give the width to text — same as Foundations. Toggle a class on <body> (not .app, which
  // app-shell owns via React) to avoid being clobbered by re-renders; CSS collapses the grid (desktop).
  useEffect_r(() => {
    if (typeof document === "undefined") return;
    const reading = !!(openTopic || openWP);
    document.body.classList.toggle("research-reading", reading);
    return () => { document.body.classList.remove("research-reading"); };
  }, [openTopic, openWP]);

  // B2: scene lists for paid topics come from the backend once Directus locks public
  // read on paid rows (the loader then only sees free scenes). Keyed by topic id →
  // scenes[] (metadata + per-scene `locked`). Empty until fetched; free path unaffected.
  const [remoteExt, setRemoteExt] = useState_r({});

  // URL-SUBSTATE: reflect research deep-state in the hash (#/research?t=<topic>&wp=1).
  useEffect_r(() => {
    if (typeof window === "undefined" || !window.LUM_ROUTE) return;
    window.LUM_ROUTE.set("t", openTopic);
    window.LUM_ROUTE.set("wp", openWP ? "1" : null);
    // SCENE-URL: keep the open scene's index in the hash so every scene is shareable.
    window.LUM_ROUTE.set("s", (openSub != null ? String(openSub) : null));
    window.LUM_ROUTE.set("g", openTopic ? researchGroupForTopic(openTopic) : null);
  }, [openTopic, openWP, openSub]);

  const backToGroup = (topic) => {
    if (onOpenGroup) onOpenGroup(researchGroupForTopic(topic));
  };
  const groupBackLabel = (topic) => {
    const group = researchGroupForTopic(topic);
    return group === "ecosystems" ? t.nav.ecosystems : group === "industries" ? t.nav.industries : t.nav.foundations;
  };

  // NAV-RESET-FIX: when a nav item is clicked (navEpoch bumps in app-shell), return
  // this section to its root. Handles clicking "Мои исследования" while a scene is
  // open — without this, setView("research") was a no-op and the scene stayed.
  // The first render is skipped so a deep link / refresh into research keeps its view.
  const navEpochSeen = useRef_r(navEpoch);
  useEffect_r(() => {
    if (navEpochSeen.current === navEpoch) return; // initial mount, nothing to reset
    navEpochSeen.current = navEpoch;
    setOpenTopic(null);
    setOpenSub(null);
    setOpenWP(false);
  }, [navEpoch]);

  // NAV v2: open a topic (and optionally a specific scene) when the dropdown tree
  // requests it (after setView("research")). detail: { id, sub? }.
  useEffect_r(() => {
    if (typeof window === "undefined") return;
    const onOpen = (e) => {
      const d = (e && e.detail) || {};
      if (!d.id) return;
      setOpenWP(false);
      setOpenTopic(d.id);
      setOpenSub((d.sub != null && !isNaN(parseInt(d.sub, 10))) ? parseInt(d.sub, 10) : null);
    };
    window.addEventListener("lum:open-topic", onOpen);
    return () => window.removeEventListener("lum:open-topic", onOpen);
  }, []);

  // B2: fetch scene lists for topics the loader has no scenes for (i.e. paid topics
  // after Directus lockdown). Runs on mount and when CMS data (re)loads. Pre-lockdown
  // every topic already has loaded scenes → nothing is fetched, free path untouched.
  useEffect_r(() => {
    if (demo || !api || !api.content) return;
    let alive = true;
    const run = () => {
      const D0 = (typeof window !== "undefined" && window.LUMINARA_DATA) || {};
      const ecs = D0.ECOSYSTEMS || [];
      const ex = D0.EXTERNAL || {};
      const missing = ecs.map(e => e.id)
        .filter(id => !(ex[id] && ex[id].scenes && ex[id].scenes.length));
      if (!missing.length) return;
      Promise.all(missing.map(id =>
        api.content.topic(id).then(r => [id, (r && r.scenes) || []]).catch(() => [id, []])
      )).then(pairs => {
        if (!alive) return;
        const map = {};
        pairs.forEach(([id, scenes]) => { if (scenes.length) map[id] = scenes; });
        if (Object.keys(map).length) {
          // shared with buildUniverse (My Universe graph), which isn't a child of Research
          window.LUMINARA_REMOTE_SCENES = { ...(window.LUMINARA_REMOTE_SCENES || {}), ...map };
          setRemoteExt(prev => ({ ...prev, ...map }));
        }
      });
    };
    run();
    window.addEventListener("lum:data-loaded", run);
    return () => { alive = false; window.removeEventListener("lum:data-loaded", run); };
  }, []);

  const wp = (typeof window !== "undefined") ? window.LUMINARA_WHITEPAPER : null;
  const wpReady = !!(wp && Array.isArray(wp.modules) && wp.modules.length);

  // ── White Paper course (F2) — special multi-section theme ──
  // Render whenever openWP: WhitePaper self-fetches the week list from the backend when
  // the loader has none (Directus locked). wpReady still lets it use loaded data first.
  if (openWP) {
    return <WhitePaper t={t} locale={locale} wp={wp || {}} onAtlas={onAtlas} onBack={() => backToGroup("ton")}
                       authReady={authReady} authRevision={authRevision} />;
  }

  const ext = D.EXTERNAL || {};
  const ecosystems = D.ECOSYSTEMS || [];
  // B2: a topic's scenes come from loaded data (free/pre-lockdown) or, if absent, from
  // the backend list fetched above (paid/post-lockdown).
  const extScenesOf = (id) =>
    (ext[id] && ext[id].scenes && ext[id].scenes.length) ? ext[id].scenes : (remoteExt[id] || []);
  // `base` is a course overview. Its real content lives in ten independent
  // chapter topics; it must never fall through to the obsolete one-topic reader.
  const baseCourseChapters = Object.keys(ext)
    .filter((id) => /^base-ch\d{2}$/.test(id))
    .sort((a, b) => a.localeCompare(b))
    .map((id) => ({ id, ecosystem: ext[id] && ext[id].ecosystem, scenes: extScenesOf(id) }));
  const moveSub = (next) => {
    setOpenSub(next);
    // C2: update in place, move focus only, never scroll the outer document (see foundations
    // moveScene). preventScroll keeps focusing the new sub-head from moving the viewport.
    requestAnimationFrame(() => requestAnimationFrame(() => {
      const el = document.querySelector(".rs-subview .rs-subhead");
      if (!el) return;
      if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
      try { const st = window.getComputedStyle(el); if (/(auto|scroll)/.test(st.overflowY)) el.scrollTop = 0; } catch (e) {}
      try { el.focus({ preventScroll: true }); } catch (e) {}
    }));
  };

  // ── Subtopic view ──
  if (openTopic && openTopic !== "base" && openSub !== null) {
    // RESEARCH-HIDE-CARDS: a hidden topic reached via direct URL (?t=…&s=…) must not render.
    if (RESEARCH_HIDE.includes(openTopic)) return <ResearchRouteFallback locale={locale} onBack={() => setOpenTopic(null)} />;
    const topicNode = ecosystems.find(e => e.id === openTopic) || ext[openTopic] || null;
    const eco = topicNode && topicNode.ecosystem ? topicNode.ecosystem : topicNode;
    const scenes = extScenesOf(openTopic);
    const sub = scenes[openSub];
    if (!sub) return <ResearchRouteFallback locale={locale} onBack={() => setOpenTopic(null)} loading={!eco && !scenes.length} />;
    return (
      <SubtopicView t={t} locale={locale} eco={eco} topic={openTopic}
                    scenes={scenes} idx={openSub} isGuest={isGuest}
                    isRead={readSet.has(`${openTopic}:ck:${window.LuminaraProgress.sceneKey(openTopic, sub, openSub)}`) || readSet.has(`${openTopic}:${openSub}`)}
                    onRead={() => markRead(openTopic, openSub, scenes)}
                    onPrev={openSub > 0 ? () => moveSub(openSub - 1) : null}
                    onNext={openSub < scenes.length - 1 ? () => moveSub(openSub + 1) : null}
                    onBack={() => backToGroup(openTopic)} backLabel={groupBackLabel(openTopic)}
                    api={api} demo={demo} authReady={authReady} authRevision={authRevision} />
    );
  }

  // ── Theme view (list of subtopics) ──
  if (openTopic) {
    // RESEARCH-HIDE-CARDS: hidden topic via direct URL (?t=ethereum) → back to the grid.
    if (RESEARCH_HIDE.includes(openTopic)) return <ResearchRouteFallback locale={locale} onBack={() => setOpenTopic(null)} />;
    if (openTopic === "base") {
      return (
        <div className="section-pad fade-in research" data-c="base">
          <button className="eco-back" onClick={() => backToGroup(openTopic)}>
            <span>←</span> {groupBackLabel(openTopic)}
          </button>
          <div className="rs-thead" data-c="base">
            <div className="rs-thead-eco">Base · {L(RUI.ecosystem, locale)}</div>
            <span className="rs-chip">{L(RUI.course, locale)} · {baseCourseChapters.length} {L(RUI.courseChapters, locale)}</span>
            <h2>Base</h2>
          </div>
          <div className="rs-section-h">{L(RUI.courseChapters, locale)} · {baseCourseChapters.length}</div>
          <div className="rs-sublist">
            {baseCourseChapters.map((chapter, i) => (
              <button className="rs-sub" key={chapter.id} data-c="base"
                      onClick={() => { setOpenTopic(chapter.id); setOpenSub(null); }}>
                <span className="rs-sub-n">{String(i + 1).padStart(2, "0")}</span>
                <span className="rs-sub-label">{researchTopicTitleR(chapter.ecosystem, chapter.id, locale)}</span>
                <span className="rs-sub-go">→</span>
              </button>
            ))}
          </div>
        </div>
      );
    }
    const topicNode = ecosystems.find(e => e.id === openTopic) || ext[openTopic] || null;
    const eco = topicNode && topicNode.ecosystem ? topicNode.ecosystem : topicNode;
    const scenes = extScenesOf(openTopic);
    const displayTitle = researchTopicTitleR(eco, openTopic, locale);
    const isGeneral = openTopic === GENERAL_TOPIC;
    const isCourseChapter = /-ch\d{2}$/.test(openTopic || "");
    if (!eco && !scenes.length) return <ResearchRouteFallback locale={locale} onBack={() => setOpenTopic(null)} loading />;
    return (
      <div className="section-pad fade-in research" data-c={openTopic}>
        <button className="eco-back" onClick={() => backToGroup(openTopic)}>
          <span>←</span> {groupBackLabel(openTopic)}
        </button>
        <div className="rs-thead" data-c={openTopic}>
          <div className="rs-thead-eco">{researchOwnerName(openTopic)} · {L(RUI.ecosystem, locale)}</div>
          <span className="rs-chip">{isGeneral ? L(RUI.general, locale) : isCourseChapter ? L(RUI.chapter, locale) : L(RUI.theme, locale)} · {scenes.length} {L(RUI.chapters, locale)}</span>
          <h2>{displayTitle}</h2>
          <p>{eco ? L(eco.blurb, locale) : ""}</p>
        </div>

        <div className="rs-section-h">{L(RUI.subtopics, locale)} · {scenes.length}</div>
        <div className="rs-sublist">
          {scenes.map((s, i) => {
            const done = readSet.has(`${openTopic}:${i}`);
            return (
              <button className={"rs-sub" + (done ? " read" : "")} key={i}
                      data-c={openTopic} onClick={() => setOpenSub(i)}>
                <span className="rs-sub-n">{String(i + 1).padStart(2, "0")}</span>
                <span className="rs-sub-label">{titleTextR(s.title, locale) || String(i + 1)}</span>
                {done ? <span className="rs-sub-read">✓</span> : <span className="rs-sub-go">→</span>}
              </button>
            );
          })}
        </div>

        {/* WP-TON-BONUS: White Paper cross-link, shown only on the TON theme */}
        {openTopic === "ton" && wpReady && (
          <>
            <div className="rs-section-h">{L(RUI.special, locale)}</div>
            <div className="rs-wp" data-c="whitepaper" onClick={() => setOpenWP(true)} role="button" tabIndex={0}
                 onKeyDown={(e) => { if (e.key === "Enter") setOpenWP(true); }}>
              <div className="rs-wp-badge">{L(RUI.bonus, locale)}</div>
              <h3>{wp.ecosystem ? titleTextR(wp.ecosystem.title, locale) : "White Paper"}</h3>
              <p className="rs-wp-blurb">{wp.ecosystem ? L(wp.ecosystem.blurb, locale) : ""}</p>
              <div className="rs-wp-foot">
                <span className="rs-count">{wp.modules.length} {L(RUI.modules, locale)} · {L(RUI.perWeek, locale)}</span>
                <span className="rs-wp-go">{L(RUI.openCourse, locale)} →</span>
              </div>
            </div>
          </>
        )}
      </div>
    );
  }
  const hubCopy = {
    foundations: {
      lede: { en: "The complete Foundations chapter catalogue.", ru: "Полный каталог глав раздела «Основания».", uk: "Повний каталог глав розділу «Основи».", kk: "«Негіздер» бөлімінің толық тараулар каталогы.", uz: "«Asoslar» bo‘limining to‘liq boblar katalogi.", es: "El catálogo completo de capítulos de Fundamentos.", fr: "Le catalogue complet des chapitres Fondamentaux.", hy: "«Հիմունքներ» բաժնի գլուխների ամբողջական կատալոգը։" },
      count: ((D.CHAPTERS || []).filter((chapter) => chapter && chapter.key !== "eth-atlas")).length,
    },
    ecosystems: {
      lede: { en: "TON, Ethereum, Bitcoin and Base.", ru: "TON, Ethereum, Bitcoin и Base.", uk: "TON, Ethereum, Bitcoin і Base.", kk: "TON, Ethereum, Bitcoin және Base.", uz: "TON, Ethereum, Bitcoin va Base.", es: "TON, Ethereum, Bitcoin y Base.", fr: "TON, Ethereum, Bitcoin et Base.", hy: "TON, Ethereum, Bitcoin և Base։" },
      count: ((catalog && catalog.ecosystems) || []).length,
    },
    industries: {
      lede: { en: "AI, trading, real-world assets and GameFi.", ru: "Искусственный интеллект, трейдинг, RWA и GameFi.", uk: "Штучний інтелект, трейдинг, RWA та GameFi.", kk: "Жасанды интеллект, трейдинг, RWA және GameFi.", uz: "Sunʼiy intellekt, treyding, RWA va GameFi.", es: "Inteligencia artificial, trading, RWA y GameFi.", fr: "Intelligence artificielle, trading, RWA et GameFi.", hy: "Արհեստական բանականություն, թրեյդինգ, RWA և GameFi։" },
      count: ((catalog && catalog.industries) || []).length,
    },
  };
  const groupTitle = (id) => id === "foundations" ? t.nav.foundations : id === "ecosystems" ? t.nav.ecosystems : t.nav.industries;
  return (
    <div className="section-pad fade-in research research-hub">
      <div className="section-head">
        <div className="kicker">{L(RUI.catalog, locale)}</div>
        <h2>{L(RUI.title, locale)}</h2>
        <p>{L(RUI.lede, locale)}</p>
      </div>
      <div className="research-hub-list">
        {((catalog && catalog.groups) || []).map((group, index) => {
          const copy = hubCopy[group.id] || {};
          return (
            <button className="research-hub-card" key={group.id} data-group={group.id}
                    onClick={() => onOpenGroup && onOpenGroup(group.id)}>
              <span className="research-hub-n">{String(index + 1).padStart(2, "0")}</span>
              <span className="research-hub-main">
                <strong>{groupTitle(group.id)}</strong>
                <small>{L(copy.lede, locale)}</small>
              </span>
              <span className="research-hub-meta">{copy.count || 0}</span>
              <span className="research-hub-go">→</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ── Subtopic reader: text left (primary) + media right + insight field ──
// SHARE-BUTTON (C3): share the current scene. Uses the existing deep-linkable hash URL
// (the router already maps every view to #/<view>, so window.location.href is shareable).
// Slug-based pretty URLs (C1/C2) are a later enhancement once Directus has a `slug` field.
// Telegram WebView → Telegram.WebApp.openLink; otherwise window.open / clipboard.
function ShareRow({ locale, title, description, slug }) {
  const L = (o) => o[locale] || o.en;
  const [copied, setCopied] = useState_r(false);
  const [busy, setBusy] = useState_r(false);       // prevents double-click duplicate requests
  const [failed, setFailed] = useState_r(false);    // short-link API unavailable → long-link fallback
  const [blocked, setBlocked] = useState_r(false);  // Issue #55: the surface refused to open the share target
  // Localized, accessible fallback note (short-link creation failed → shared the long link).
  const SHARE_ERR = { ru: "Поделились обычной ссылкой (короткую не удалось создать).",
    en: "Shared the standard link (couldn't create a short one).",
    uk: "Поділилися звичайним посиланням (коротке не вдалося створити).",
    kk: "Әдеттегі сілтемемен бөлістік (қысқасын жасау мүмкін болмады).",
    uz: "Oddiy havola bilan ulashildi (qisqasini yaratib bo'lmadi).",
    es: "Se compartió el enlace normal (no se pudo crear uno corto).",
    fr: "Lien standard partagé (impossible de créer un lien court).",
    hy: "Կիսվեցինք սովորական հղումով (կարճը չստացվեց ստեղծել)։" };
  // Issue #55: the share target could not be opened at all (popup blocked / no capability).
  // Localized in all eight product locales, like every other user-visible string here.
  const SHARE_BLOCKED = { ru: "Не удалось открыть окно шеринга. Нажми ещё раз.",
    en: "Couldn't open the share window. Tap again.",
    uk: "Не вдалося відкрити вікно поширення. Натисни ще раз.",
    kk: "Бөлісу терезесін ашу мүмкін болмады. Қайта басыңыз.",
    uz: "Ulashish oynasini ochib bo'lmadi. Yana bosing.",
    es: "No se pudo abrir la ventana para compartir. Púlsalo de nuevo.",
    fr: "Impossible d'ouvrir la fenêtre de partage. Appuyez à nouveau.",
    hy: "Չհաջողվեց բացել կիսվելու պատուհանը։ Փորձեք կրկին։" };
  // Issue #59: every channel goes through the ONE builder (window.LUMINARA_buildShareLink).
  // It normalizes the route, attaches the inviter's ref + channel/campaign UTM BEFORE the
  // '#', and returns the canonical same-origin /share/article URL plus each channel's
  // outgoing URL. A scene WITHOUT a slug still gets a concrete route (the router keeps the
  // open scene's index in the hash — see SCENE-URL), never a "random current URL".
  const route = (function () {
    if (typeof window === "undefined") return "#/atlas";
    if (slug) return "#/t/" + slug;
    const h = window.location.hash;
    return (h && h.indexOf("#/") === 0) ? h : "#/atlas";
  })();
  const refCode = (typeof window !== "undefined") ? window.LUMINARA_REF_CODE : "";
  const build = (channel) => {
    if (typeof window === "undefined" || typeof window.LUMINARA_buildShareLink !== "function") return null;
    return window.LUMINARA_buildShareLink({
      channel: channel, route: route, locale: locale || "en",
      title: title || "Luminara", description: description || "",
      refCode: refCode, // contentKey omitted → derived from route, stable across all locales
    });
  };
  const enc = encodeURIComponent;
  // Create a short /s/:code for the SAME payload the builder produced. Same-origin POST; no
  // secrets/internal IDs ever leave the client. Resolves to the short URL, or null on ANY
  // failure so every caller can fall back to the long link. (Short-link extension §3.2/§3.5.)
  const createShort = (channel) => {
    if (typeof window === "undefined" || typeof window.LUMINARA_buildShareCreatePayload !== "function") {
      return Promise.resolve(null);
    }
    const payload = window.LUMINARA_buildShareCreatePayload({
      channel: channel, route: route, locale: locale || "en",
      title: title || "Luminara", description: description || "", refCode: refCode,
    });
    // Issue #60: when the live authenticated API client is available, create the short link through
    // it so the logged-in user is stored as the real creator (the client attaches the bearer token;
    // the server derives the creator via freshAuth and NEVER trusts a body field). Only a genuinely
    // anonymous reader (no live client) falls back to the same-origin fetch (creator NULL).
    const client = (typeof window !== "undefined") ? window.LUMINARA_API : null;
    if (client && client.shareLinks && typeof client.shareLinks.create === "function") {
      return client.shareLinks.create(payload)
        .then((j) => (j && j.url ? j.url : null))
        .catch(() => null);
    }
    return fetch("/api/v1/share-links", {
      method: "POST", credentials: "same-origin",
      headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
    }).then((r) => (r && r.ok ? r.json() : null))
      .then((j) => (j && j.url ? j.url : null))
      .catch(() => null);
  };
  // Telegram in-app opener (no popup blocker inside the Telegram webview).
  const tgOpener = () => {
    try { if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.openLink) return window.Telegram.WebApp.openLink.bind(window.Telegram.WebApp); } catch (e) {}
    return null;
  };
  // Pre-open a blank tab synchronously (keeps the user gesture so the browser won't block the
  // popup), then sever the opener for noopener,noreferrer-equivalent privacy before navigating.
  const preopen = () => {
    try { const w = window.open("", "_blank", "noopener,noreferrer"); if (w) { try { w.opener = null; } catch (e) {} } return w || null; } catch (e) { return null; }
  };
  // The actual channel hop. The CALLER owns the busy flag so this can also serve as the
  // Facebook fallback after a dismissed/failed Web Share without tripping the double-click
  // guard. `preopened` is a tab captured synchronously inside the original click (or null when
  // Telegram's opener is used / the gesture is already spent). Resolves to whether a target was
  // actually opened, and ALWAYS clears busy — success, rejection or refusal (Issue #55).
  const runChannelShare = (channel, preopened) => {
    const link = build(channel);
    if (!link) { setBusy(false); return Promise.resolve(false); }
    const tg = tgOpener();
    const win = tg ? null : preopened;
    return createShort(channel).then((shortUrl) => {
      const finalUrl = shortUrl ? window.LUMINARA_wrapShareOutgoing(channel, shortUrl, link.text) : link.outgoingUrl;
      if (!shortUrl) setFailed(true); // shared via the long-link fallback; tell the user quietly
      let opened = false;
      if (tg) { try { tg(finalUrl); opened = true; } catch (e) { opened = false; } }
      else if (win) {
        try { win.location.replace(finalUrl); opened = true; }
        catch (e) { try { win.close(); } catch (e2) {} opened = !!window.open(finalUrl, "_blank", "noopener,noreferrer"); }
      } else { opened = !!window.open(finalUrl, "_blank", "noopener,noreferrer"); }
      return opened;
    }).catch(() => false).then((opened) => { setBusy(false); return opened; });
  };
  const openChannel = (channel) => {
    const link = build(channel);
    if (!link || busy) return; // build() is the long-link authority + fallback source
    setBusy(true); setFailed(false); setBlocked(false);
    const tg = tgOpener();
    runChannelShare(channel, tg ? null : preopen());
  };
  const copy = () => {
    const link = build("copy");
    if (!link || !link.copyUrl || busy) return;
    setBusy(true); setFailed(false);
    createShort("copy").then((shortUrl) => {
      const toCopy = shortUrl || link.copyUrl; // fall back to the long canonical link
      if (!shortUrl) setFailed(true);
      try {
        navigator.clipboard.writeText(toCopy).then(function () {
          setCopied(true); setTimeout(function () { setCopied(false); }, 1800);
        });
      } catch (e) {}
      setBusy(false);
    });
  };
  // Issue #55 — Facebook from mobile / inside the Telegram Mini App.
  //
  // The previous flow navigated to the custom `fb://facewebmodal` scheme and, 900 ms later,
  // blindly called window.open(sharer). Inside Telegram that timer IS the reported bug: the
  // sharer loads in Telegram's own WebView, where the reader is not signed in, so they land on
  // a Facebook LOGIN page with no way back to the share. The scheme guess is gone too — no
  // browser API can prove that a native app is installed, so the flow is now driven by what
  // the surface can actually do and every branch ends in something usable.
  const openFacebook = () => {
    const link = build("facebook");
    if (!link || busy) return;
    const caps = (typeof window.LUMINARA_shareCapabilities === "function")
      ? window.LUMINARA_shareCapabilities(window, navigator) : null;
    const plan = (caps && typeof window.LUMINARA_facebookShareStrategy === "function")
      ? window.LUMINARA_facebookShareStrategy(caps) : null;
    // Older share-link.js without the strategy: the shared channel flow is already
    // Telegram-safe (it prefers Telegram.WebApp.openLink), so degrade to it rather than guess.
    if (!plan) { openChannel("facebook"); return; }

    setBusy(true); setFailed(false); setBlocked(false);

    if (plan.primary !== "web-share") {
      // 'sharer-external' → Telegram.WebApp.openLink hands the sharer to the system browser.
      // 'sharer-tab'      → a tab pre-opened inside this click, so it is not popup-blocked.
      runChannelShare("facebook", plan.opener === "telegram" ? null : preopen());
      return;
    }

    // navigator.share MUST run inside the original click. Awaiting the short-link POST first
    // would spend the user activation and iOS would reject the call, so this branch shares the
    // canonical /share/article URL the builder already produced — the contract explicitly
    // allows either that canonical URL or the short one, and it carries the same ref/UTM.
    let shared;
    try { shared = navigator.share({ title: link.text, text: link.text, url: link.shareUrl }); }
    catch (e) { shared = Promise.reject(e); }
    Promise.resolve(shared).then(
      () => { setBusy(false); }, // handed to the OS sheet; the OS may route it to Facebook
      (err) => {
        // The reader deliberately dismissed the sheet — respect that, do not force a second UI.
        if (err && err.name === "AbortError") { setBusy(false); return; }
        if (!plan.fallback) { setBusy(false); setBlocked(true); return; }
        // The gesture is spent, so no pre-opened tab: Telegram opens externally, a plain
        // browser may refuse the popup — in which case say so instead of spinning.
        runChannelShare("facebook", null).then((opened) => { if (!opened) setBlocked(true); });
      }
    );
  };
  return (
    <div className="rs-share">
      <span className="rs-share-lbl">{L(RUI.share)}</span>
      <button className="rs-share-btn" onClick={copy} disabled={busy} aria-busy={busy} aria-label="Copy link">{copied ? ("✓ " + L(RUI.copied)) : "🔗"}</button>
      <button className="rs-share-btn" onClick={() => openChannel("telegram")} disabled={busy} aria-busy={busy} aria-label="Telegram">Telegram</button>
      <button className="rs-share-btn" onClick={() => openChannel("x")} disabled={busy} aria-busy={busy} aria-label="X">X</button>
      <button className="rs-share-btn" onClick={openFacebook} disabled={busy} aria-busy={busy} aria-label="Facebook">Facebook</button>
      {failed ? <span className="rs-share-note" role="status">{L(SHARE_ERR)}</span> : null}
      {blocked ? <span className="rs-share-note" role="status">{L(SHARE_BLOCKED)}</span> : null}
    </div>
  );
}

// B2 (CONTENT-GATE) — paywall shown in place of a locked scene/week body. Variant 3:
// a real "Get access" button whose click shows a soft "soon" note (no payment flow yet;
// wire the checkout here later without re-layout).
const PAYWALL_TX = {
  title: { ru: "Раздел с подпиской", en: "Subscription section", uk: "Розділ з підпискою", kk: "Жазылыммен бөлім", uz: "Obuna bilan bo‘lim", es: "Sección con suscripción", fr: "Section avec abonnement", hy: "Բաժանորդագրությամբ բաժին" },
  body:  { ru: "Этот материал открывается по подписке.", en: "This material is available with a subscription.", uk: "Цей матеріал відкривається за підпискою.", kk: "Бұл материал жазылым арқылы ашылады.", uz: "Bu material obuna orqali ochiladi.", es: "Este material está disponible con suscripción.", fr: "Ce contenu est accessible avec un abonnement.", hy: "Այս նյութը հասանելի է բաժանորդագրությամբ։" },
  cta:   { ru: "Оформить доступ", en: "Get access", uk: "Оформити доступ", kk: "Қол жеткізу", uz: "Ruxsat olish", es: "Obtener acceso", fr: "Obtenir l'accès", hy: "Ստանալ հասանելիություն" },
  soon:  { ru: "Скоро подключим оплату 🙌", en: "Payments coming soon 🙌", uk: "Оплату скоро підключимо 🙌", kk: "Төлемді жақында қосамыз 🙌", uz: "Toʻlov tez orada 🙌", es: "Pagos muy pronto 🙌", fr: "Paiements bientôt 🙌", hy: "Վճարումները շուտով 🙌" },
};
function Paywall({ locale }) {
  const [soon, setSoon] = useState_r(false);
  const tx = (k) => (PAYWALL_TX[k][locale] || PAYWALL_TX[k].en);
  return (
    <div className="rs-paywall">
      <div className="rs-paywall-lock" aria-hidden="true">🔒</div>
      <div className="rs-paywall-title">{tx("title")}</div>
      <div className="rs-paywall-body">{tx("body")}</div>
      <button className="btn primary rs-paywall-cta" onClick={() => setSoon(true)}>{tx("cta")}</button>
      {soon && <div className="rs-paywall-soon">{tx("soon")}</div>}
    </div>
  );
}

// The server is the sole authority on access. This hook deliberately knows neither
// roles nor plans: it waits for the app session to resolve, asks the protected
// endpoint, and retries after every resolved session revision. A transport failure
// is distinct from a denial, so a temporary outage can never become a false paywall.
function useGatedContent({ api, fetchKey, gated, demo, authReady, authRevision, fetcher, pick }) {
  const [gate, setGate] = useState_r(null);
  const [retryNonce, setRetryNonce] = useState_r(0);
  useEffect_r(() => {
    if (!gated) { setGate(null); return; }
    if (!authReady) { setGate({ fetchKey, loading: true }); return; }
    if (demo || !api || !api.content) { setGate({ fetchKey, locked: true }); return; }
    let alive = true;
    setGate({ fetchKey, loading: true });
    Promise.resolve(fetcher(api)).then((response) => {
      if (!alive) return;
      if (response && response.locked) { setGate({ fetchKey, locked: true }); return; }
      const ready = pick(response);
      setGate(ready ? { fetchKey, ...ready } : { fetchKey, error: true });
    }).catch(() => { if (alive) setGate({ fetchKey, error: true }); });
    return () => { alive = false; };
    // fetcher/pick are purpose-built for the current fetchKey and must not make
    // this effect run on every render. authRevision is the explicit re-check signal.
  }, [gated, fetchKey, demo, authReady, authRevision, retryNonce]);
  // Effects run after render. Ignore a previous material's state during a key
  // change so its protected body is never painted while the next fetch starts.
  const currentGate = gated && gate && gate.fetchKey === fetchKey ? gate : null;
  return { gate: currentGate || (gated ? { loading: true } : null), retry: () => setRetryNonce((n) => n + 1) };
}

const GATE_ERROR_TX = {
  title: { ru: "Не удалось проверить доступ", en: "We could not verify access", uk: "Не вдалося перевірити доступ", kk: "Қолжетімділікті тексеру мүмкін болмады", uz: "Kirishni tekshirib bo‘lmadi", es: "No se pudo comprobar el acceso", fr: "Impossible de vérifier l’accès", hy: "Չհաջողվեց ստուգել հասանելիությունը" },
  body: { ru: "Проверьте подключение и повторите попытку.", en: "Check your connection and try again.", uk: "Перевірте з’єднання та повторіть спробу.", kk: "Байланысты тексеріп, қайталап көріңіз.", uz: "Ulanishni tekshiring va qayta urinib ko‘ring.", es: "Comprueba la conexión e inténtalo de nuevo.", fr: "Vérifiez votre connexion et réessayez.", hy: "Ստուգեք կապը և փորձեք կրկին։" },
  retry: { ru: "Повторить", en: "Try again", uk: "Повторити", kk: "Қайталау", uz: "Qayta urinish", es: "Reintentar", fr: "Réessayer", hy: "Կրկնել" },
};
function GateError({ locale, onRetry }) {
  const tx = (k) => GATE_ERROR_TX[k][locale] || GATE_ERROR_TX[k].en;
  return <div className="rs-gate-error" role="alert">
    <div className="rs-gate-error-title">{tx("title")}</div>
    <div className="rs-gate-error-body">{tx("body")}</div>
    <button className="btn primary" onClick={onRetry}>{tx("retry")}</button>
  </div>;
}

// COURSE-NAV (#54): one reader pager for both placements.  A course reader
// supplies the route-preserving movement callbacks; this component owns only
// the shared visual state (labels, position and disabled edge buttons).
function ResearchPager({ t, index, total, onPrev, onNext, placement = "bottom" }) {
  return (
    <div className={"rs-pager" + (placement === "top" ? " rs-pager-top" : "")}>
      <button className="btn" disabled={!onPrev} onClick={onPrev || undefined}
              style={{ opacity: onPrev ? 1 : 0.4 }}>← {t.previous}</button>
      <span className="rs-pager-pos">{index + 1} / {total}</span>
      <button className="btn primary" disabled={!onNext} onClick={onNext || undefined}
              style={{ opacity: onNext ? 1 : 0.4 }}>{t.next} →</button>
    </div>
  );
}

function SubtopicView({ t, locale, eco, topic, scenes, idx, isGuest, isRead, onRead, onPrev, onNext, onBack, backLabel, api, demo, authReady, authRevision }) {
  const sub = scenes[idx];
  const textRef = useRef_r(null);
  const readEndRef = useRef_r(null);
  const dwellTimer = useRef_r(null);
  const [marked, setMarked] = useState_r(isRead);
  const [premiumItems, setPremiumItems] = useState_r([]);
  const interactiveScene = Number.isInteger(Number(sub && sub.sort)) ? Number(sub.sort) : idx;

  // The secure registry is the sole source of CTA metadata.  In particular, a
  // Russian-only module is not advertised while another locale is active.
  useEffect_r(() => {
    setPremiumItems([]);
    if (demo || !authReady || !api || !api.content || !api.content.interactives) return undefined;
    let alive = true;
    api.content.interactives(topic, interactiveScene, locale)
      .then((data) => { if (alive) setPremiumItems(Array.isArray(data && data.items) ? data.items : []); })
      .catch(() => { if (alive) setPremiumItems([]); });
    return () => { alive = false; };
  }, [topic, interactiveScene, locale, api, demo, authReady, authRevision]);

  // Read detection: fire when the text bottom is reached AND the user dwells
  // ~1.5s there (a fast scroll-to-bottom flick won't count).
  useEffect_r(() => {
    setMarked(isRead);
  }, [idx, isRead]);

  useEffect_r(() => {
    if (marked) return;
    const end = readEndRef.current;
    if (!end || typeof IntersectionObserver === "undefined") return;
    const root = end.closest(".view-scroll");
    const observer = new IntersectionObserver((entries) => {
      const reachedBottom = entries.some((entry) => entry.isIntersecting);
      if (reachedBottom && !dwellTimer.current) {
        dwellTimer.current = setTimeout(() => {
          setMarked(true);
          onRead && onRead();
        }, 1500);
      } else if (!reachedBottom && dwellTimer.current) {
        clearTimeout(dwellTimer.current); dwellTimer.current = null;
      }
    }, { root, threshold: 0.75 });
    observer.observe(end);
    return () => {
      observer.disconnect();
      if (dwellTimer.current) { clearTimeout(dwellTimer.current); dwellTimer.current = null; }
    };
  }, [idx, marked]);

  const tags = Array.isArray(sub.tags) ? sub.tags : [];
  // CONTENT-ACCESS-FLAG (B3): per-scene free/paid badge.
  const subAccess = (sub.access === "paid" ? "paid" : "free");
  const subAccLab = (typeof window !== "undefined" && typeof window.luminaraAccessLabel === "function")
    ? window.luminaraAccessLabel(locale, subAccess)
    : (subAccess === "paid" ? "With subscription" : "Without subscription");

  // Every paid scene is read through the protected endpoint. The server decides
  // whether this particular scene is a teaser, paid, staff-visible or expired;
  // the browser never keeps a parallel course/plan allowlist.
  const gated = subAccess === "paid";
  const ck = sub.ck || (topic + ":" + (sub.sort != null ? sub.sort : idx));
  const { gate, retry: retryGate } = useGatedContent({
    api, fetchKey: "scene:" + ck, gated, demo, authReady, authRevision,
    fetcher: (a) => a.content.scene(ck),
    pick: (r) => {
      if (!r || !r.scene) return null;
      if (r.topic_media && window.LUMINARA_DATA) {
        window.LUMINARA_DATA.MEDIA = window.LUMINARA_DATA.MEDIA || {};
        window.LUMINARA_DATA.MEDIA[topic] = r.topic_media;
      }
      // A paid scene's sources may be tiered. Keep them in the same protected
      // server response as its body and media; never fall back to public metadata.
      return { body: r.scene.body, insight: r.scene.insight, media: r.scene.media, sources: r.scene.sources };
    },
  });

  // TON-LONGREAD (v102.2): a FREE scene renders sub.body, which is the SHORT inline stub whenever the
  // browser→Directus load failed (the full longread never reached the client). Fetch the full body from
  // the backend content endpoint (server-side Directus by token — no browser CORS / Public-role issue)
  // and prefer it. If the fetch fails or returns nothing, we keep sub.* so the scene is NEVER empty.
  const [freeFull, setFreeFull] = useState_r(null);
  useEffect_r(() => {
    setFreeFull(null);
    if (gated || demo || !api || !api.content) return; // paid handled by gate; demo has no backend
    let alive = true;
    (async () => {
      try {
        const r = await api.content.scene(ck);
        if (!alive || !r || r.locked || !r.scene) return;
        const b = r.scene.body;
        const hasBody = b && (typeof b === "string"
          ? b.trim() !== ""
          : Object.keys(b).some(k => b[k] && String(b[k]).trim() !== ""));
        if (hasBody) setFreeFull({ body: r.scene.body, insight: r.scene.insight, media: r.scene.media, sources: r.scene.sources });
      } catch (e) { /* keep sub.* as fallback — never leave the scene empty */ }
    })();
    return () => { alive = false; };
  }, [topic, idx, ck, gated, demo]);
  // C5 read-once: a guest may read ONE free article; further free articles show the Paywall.
  // The first free article opened claims the slot (localStorage); paid articles are handled by
  // the backend gate above and do not consume the guest's free read.
  const [guestBlocked, setGuestBlocked] = useState_r(false);
  useEffect_r(() => {
    if (!isGuest || gated) { setGuestBlocked(false); return; }
    try {
      const K = "lum_guest_article";
      const stored = localStorage.getItem(K);
      if (!stored) { localStorage.setItem(K, ck); setGuestBlocked(false); }
      else setGuestBlocked(stored !== ck);
    } catch (e) { setGuestBlocked(false); }
  }, [isGuest, gated, ck]);

  const locked  = (gated && !!(gate && gate.locked)) || guestBlocked;
  const loading = gated && !!(gate && gate.loading);
  const gateError = gated && !!(gate && gate.error);
  // P0#2: for a paid (gated) scene, body/insight/media come ONLY from the authenticated content
  // endpoint (gate.*). Never fall back to sub.* (public feed) — that would leak paid text if it
  // were ever present client-side. Free (non-gated) scenes prefer the full backend body (freeFull,
  // TON-LONGREAD) and fall back to sub.* (loaded/inline) so they are never empty.
  const effBody    = gated ? ((gate && gate.body    != null) ? gate.body    : "")   : ((freeFull && freeFull.body    != null) ? freeFull.body    : sub.body);
  const effInsight = gated ? ((gate && gate.insight != null) ? gate.insight : "")   : ((freeFull && freeFull.insight != null) ? freeFull.insight : sub.insight);
  const effMedia   = gated ? ((gate && gate.media   != null) ? gate.media   : null) : ((freeFull && freeFull.media   != null) ? freeFull.media   : sub.media);
  // MEDIA-SOURCES-ONLY (#63): the bibliography belongs in the media column and
  // must come from the same resolved server response as the visible scene.
  const effSources = gated
    ? ((gate && Array.isArray(gate.sources)) ? gate.sources : [])
    : ((freeFull && Array.isArray(freeFull.sources)) ? freeFull.sources : sub.sources);

  const titleRes = resolveField(sub.title, locale);
  const bodyRes = (!locked && effBody != null && effBody !== "")
    ? resolveBodyField(effBody, locale)
    : { value: "", locale: "", fallback: false };
  const fbRes = titleRes.fallback ? titleRes : (bodyRes.fallback ? bodyRes : null);
  const localeNotice = fbRes ? fieldNotice(fbRes, locale) : "";

  return (
    <div className="section-pad fade-in research rs-subview" data-c={topic}>
      <button className="eco-back" onClick={onBack}>
        <span>←</span> {backLabel || L(RUI.backTheme, locale)}
      </button>

      <div className="rs-subhead" data-c={topic}>
        <div className="rs-thead-eco">{researchOwnerName(topic)} · {L(RUI.ecosystem, locale)}</div>
        <div className="rs-subhead-row">
          <span className="rs-sub-n big">{String(idx + 1).padStart(2, "0")}</span>
          <h2>{titleRes.value}</h2>
          <span className={"access-badge access-" + subAccess}>{subAccLab}</span>
          {marked && <span className="rs-read-badge">✓ {L(RUI.read, locale)}</span>}
        </div>
      </div>

      {localeNotice ? (
        <div className="rs-locale-notice" role="status">{localeNotice}</div>
      ) : null}

      <ResearchPager t={t} index={idx} total={scenes.length}
                     onPrev={onPrev} onNext={onNext} placement="top" />

      <div className="rs-body-grid">
        {/* RIGHT — media links by category (floated; must precede text in DOM so prose
            wraps beside AND under it — SCENE-TEXT-UNDER-ASIDE). Hidden when locked. */}
        {!locked && (
          <aside className="rs-media">
            <div className="rs-media-h">{L(RUI.media, locale)}</div>
            <MediaColumn media={effMedia} locale={locale} topicKey={topic} mediaKey={"scene:" + ck}
              scene={interactiveScene} api={api} premiumItems={premiumItems} showPremium={premiumItems.length > 0}
              resetKey={topic + ":" + idx} sources={effSources} />
          </aside>
        )}

        {/* LEFT — text (primary) */}
        <div className="rs-text" ref={textRef}>
          {locked ? (
            <Paywall locale={locale} />
          ) : gateError ? (
            <GateError locale={locale} onRetry={retryGate} />
          ) : loading ? (
            <p className="rs-text-body rs-text-loading">…</p>
          ) : (
            <React.Fragment>
              {window.SceneProse
                ? <div className="rs-text-body rs-text-md"><window.SceneProse text={resolveBody(effBody, locale)} /></div>
                : <p className="rs-text-body">{resolveBody(effBody, locale)}</p>}
              {L(effInsight, locale) ? <div className="rs-text-insight">{L(effInsight, locale)}</div> : null}
              {tags.length ? (
                <div className="rs-tagrow">{tags.map(tg => <span className="rs-tag" key={tg}>{tg}</span>)}</div>
              ) : null}

              <ShareRow locale={locale} title={titleRes.value}
                        description={resolveBody(effBody, locale).replace(/[#>*_`]/g, " ").replace(/\s+/g, " ").trim().slice(0, 240)}
                        slug={sub.slug} />

              {/* Insight field at the end of the text */}
              <InsightField topic={topic} idx={idx} locale={locale} api={api} demo={demo} />

              {/* POINTS-VERIFY: server-verified quiz appears once, at the end of the
                  topic (last subtopic). ThemeQuiz is a global component (foundations.jsx);
                  it fetches /quiz?topic= and renders nothing when the topic has no quiz. */}
              {!onNext && <ThemeQuiz topic={topic} locale={locale} onBackToChapter={onBack} />}
            </React.Fragment>
          )}

          <span ref={readEndRef} className="scene-read-sentinel" aria-hidden="true" />

          <ResearchPager t={t} index={idx} total={scenes.length}
                         onPrev={onPrev} onNext={onNext} />
        </div>
      </div>
    </div>
  );
}

// Parse a YouTube id from an id or any common URL form.
function ytId(v) {
  if (!v) return null;
  if (/^[\w-]{11}$/.test(v)) return v;                       // already an id
  const m = String(v).match(/(?:youtu\.be\/|v=|embed\/|shorts\/)([\w-]{11})/);
  return m ? m[1] : null;
}

// Lazy YouTube embed: shows the thumbnail (no iframe, no cookies, no network to
// Google) until clicked; on click swaps in the privacy-enhanced nocookie iframe
// with autoplay. Keeps the page light and avoids loading YouTube on every view.
function YouTubeEmbed({ id, title, resetKey }) {
  const [play, setPlay] = useState_r(false);
  // VIDEO-NO-AUTOPLAY: never carry a playing video across navigation. Reset to the poster whenever
  // the video id, scene/page, or language changes — playback starts ONLY on the user's click.
  useEffect_r(() => { setPlay(false); }, [id, resetKey]);
  if (!id) return null;
  if (play) {
    return (
      <div className="rs-yt">
        <iframe className="rs-yt-frame"
                src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0`}
                title={title || "video"} frameBorder="0" allowFullScreen
                allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" />
      </div>
    );
  }
  return (
    <button className="rs-yt rs-yt-poster" onClick={() => setPlay(true)} aria-label={title || "Play video"}
            style={{ backgroundImage: `url(https://i.ytimg.com/vi/${id}/hqdefault.jpg)` }}>
      <span className="rs-yt-play">▶</span>
      {title ? <span className="rs-yt-title">{title}</span> : null}
    </button>
  );
}

// Media column. Reads scene.media = { video:[{youtube|url, title}], audio:[...], files:[...] }.
// Video → lazy YouTube embeds (E1). Audio → YouTube links (free, per wallet/media decision).
// Files → download links (real storage lands with E2). Empty categories show an honest hint.
// SOURCE-RENDERER (#63): the ONE renderer every course template uses (Foundations, AI, Trading,
// ecosystem topics, White Paper). It never decides policy itself — the shared model
// (v62/sources.js) normalizes the entry, applies the http(s) allowlist and builds the a11y label.
// An entry without an allowlisted destination renders as bibliography text, never a fake link.
// `variant` only selects class names so each template keeps its own visual treatment.
function SourceList({ sources, locale, variant = "list", showDomain = true }) {
  const M = (typeof window !== "undefined" && window.LuminaraSources) || null;
  const list = M ? M.usableSources(sources) : (Array.isArray(sources) ? sources : []);
  if (!list.length) return null;
  const cls = "rs-srclist rs-srclist-" + variant;
  const openExternal = (e, href) => {
    // Inside the Mini App the in-app WebView must not navigate away; use the external browser flow.
    if (!M) return;
    const tg = (typeof window !== "undefined") && window.Telegram && window.Telegram.WebApp;
    if (tg && typeof tg.openLink === "function") { e.preventDefault(); M.openSource(href); }
  };
  return (
    <ul className={cls}>
      {list.map((s, i) => {
        const title = M ? M.sourceTitle(s, locale) : srcTitle(s, locale);
        const link = M ? M.sourceLink(s, locale) : null;
        if (!link) {
          return (
            <li key={i} className="rs-src-item rs-src-plain">
              <span className="rs-src-bullet" aria-hidden="true">•</span>
              <span className="rs-src-title">{title}</span>
            </li>
          );
        }
        const domain = showDomain && link.kind === "external" ? link.domain : "";
        return (
          <li key={i} className={"rs-src-item rs-src-" + link.kind}>
            <a className="rs-src-link" href={link.href} target="_blank" rel="noopener noreferrer"
               aria-label={link.aria} onClick={(e) => openExternal(e, link.href)}>
              <span className="rs-src-ic" aria-hidden="true">{link.icon}</span>
              <span className="rs-src-title">{title}</span>
              {domain ? <span className="rs-src-domain">{domain}</span> : null}
            </a>
          </li>
        );
      })}
    </ul>
  );
}
window.SourceList = SourceList;

// ── Media visibility policy (Issue #23) ──────────────────────────────────────────────────────
// A row is rendered ONLY when there is something real to show it. No "coming soon", no disabled
// CTA, no empty group, no `href="#"`, for ANY theme/locale/section/future media type. This is a
// pure function (no DOM, no React) precisely so it can be unit-tested directly and reused by
// every caller that embeds MediaColumn (research.jsx's own subview AND foundations.jsx's scene
// view, which has a real fallback — SceneArt — that must come back when there is nothing to
// show). Directus rows are never touched; this is frontend visibility only.
const KNOWN_LIVE_DEMO_TOPICS = new Set(["preinternet", "web1", "web2", "web3", "web30", "netocracy", "economy", "social", "bitcoin"]);
// The Bitcoin safety trainer is a first-party learning aid that should be reachable with the
// Bitcoin topic even before an editor attaches a media row in Directus. Other demos remain CMS
// controlled through their live_demo flag.
const BUILTIN_LIVE_DEMO_TOPICS = new Set(["bitcoin"]);

function isHttpUrl(u) {
  if (typeof u !== "string") return false;
  const s = u.trim();
  if (!s) return false;
  try { const parsed = new URL(s); return parsed.protocol === "http:" || parsed.protocol === "https:"; }
  catch (e) { return false; }
}

function resolveTableVideoId(mediaKey, topicKey, locale) {
  const mediaTable = (typeof window !== "undefined" && window.LUMINARA_DATA && window.LUMINARA_DATA.MEDIA) || {};
  // Scene-scoped media wins over topic media. It is used for a free Nursery
  // lesson whose key collides with the separate paid Trading course.
  const mediaRec = (mediaKey && mediaTable[mediaKey]) || (topicKey ? mediaTable[topicKey] : null);
  if (!mediaRec || !mediaRec.video) return null;
  let vm = mediaRec.video;
  // Directus JSON fields can arrive as a JSON STRING rather than an object — parse it.
  if (typeof vm === "string") {
    const s = vm.trim();
    if (s.charAt(0) === "{") { try { vm = JSON.parse(s); } catch (e) { return null; } }
    else return ytId(vm); // plain URL string
  }
  const url = (vm && typeof vm === "object")
    ? (vm[locale] || (vm.__locale_fallback === false ? "" : vm.en) || "")
    : "";
  return url ? ytId(url) : null;
}

function computeMediaVisibility({ media, locale, sources, topicKey, mediaKey, mapCta }) {
  const m = media || {};
  const video = (Array.isArray(m.video) ? m.video : [])
    .map((v) => ({ ...v, id: ytId(v.youtube || v.url || v.id) }))
    .filter((v) => !!v.id);
  const tableVideoId = resolveTableVideoId(mediaKey, topicKey, locale);

  const audio = (Array.isArray(m.audio) ? m.audio : []).map((a) => {
    if (!a) return null;
    const ytid = a.youtube ? ytId(a.youtube) : null;
    const href = isHttpUrl(a.url) ? a.url : (ytid ? `https://youtu.be/${ytid}` : null);
    return href ? { title: a.title, href } : null;
  }).filter(Boolean);

  const files = (Array.isArray(m.files) ? m.files : [])
    .filter((f) => f && isHttpUrl(f.url));

  const rawLiveDemo = (() => {
    const mediaTable = (typeof window !== "undefined" && window.LUMINARA_DATA && window.LUMINARA_DATA.MEDIA) || {};
    const rec = topicKey ? mediaTable[topicKey] : null;
    return rec ? rec.live_demo : undefined;
  })();
  // Directus booleans can arrive as true/false, "true"/"false", or 1/0. A demo is only shown
  // when the flag is truthy AND the topic actually maps to a real, supported demo file — a
  // stale/mistyped topicKey must not open a 404 iframe.
  const liveDemoEnabledByMedia = rawLiveDemo === true || rawLiveDemo === "true" || rawLiveDemo === 1 || rawLiveDemo === "1";
  const liveDemoOn = KNOWN_LIVE_DEMO_TOPICS.has(topicKey)
    && (BUILTIN_LIVE_DEMO_TOPICS.has(topicKey) || liveDemoEnabledByMedia);

  // Keep MediaColumn's inventory identical to the renderer's inventory. The
  // shared model recognizes every canonical Directus and legacy source shape.
  const sourceModel = (typeof window !== "undefined" && window.LuminaraSources) || null;
  const srcs = sourceModel
    ? sourceModel.usableSources(sources)
    : (Array.isArray(sources) ? sources.filter((s) => !!s) : []);

  const hasVideo = !!tableVideoId || video.length > 0;
  const hasAudio = audio.length > 0;
  const hasFiles = !!mapCta || files.length > 0;
  const hasSources = srcs.length > 0;
  const hasAny = hasVideo || hasAudio || hasFiles || liveDemoOn || hasSources;

  return { tableVideoId, video, audio, files, liveDemoOn, srcs, hasVideo, hasAudio, hasFiles, hasSources, hasAny };
}
// Exposed for foundations.jsx (needs to know up front whether to render its SceneArt fallback
// instead of an empty aside) and for the functional test suite (scripts/media-visibility-check.mjs
// imports this directly — no grep, real inputs/outputs).
if (typeof window !== "undefined") window.computeMediaVisibility = computeMediaVisibility;

// ── Shared accessible modal shell (Issue #19) ────────────────────────────────────────────────
// ONE reusable dialog for the research live-demo, the foundations chapter map, and the new
// premium ARPANET interactives (#24) — replaces three near-identical full-viewport portals.
// - Windowed on desktop: min(1100px, calc(100vw - 48px)) × calc(100dvh - 48px), not full-screen.
// - The close button lives in the OUTER document (never inside the iframe), so it stays visible
//   no matter how far the embedded page scrolls internally.
// - Close via the button, Escape, or a click on the backdrop (outside the shell).
// - Focus moves to the close button on open; a Shift+Tab there cannot escape the dialog
//   backwards (a full trap across the iframe boundary is out of scope — see IMPLEMENTATION-NOTES);
//   focus returns to the element that opened the modal (returnFocusRef) on close.
// - Mobile: goes edge-to-edge with safe-area padding, no horizontal scroll.
function LuminaraModal({ title, src, onClose, returnFocusRef, fullscreen = false }) {
  const closeBtnRef = useRef_r(null);
  useEffect_r(() => {
    const previouslyFocused = (returnFocusRef && returnFocusRef.current) || document.activeElement;
    try { closeBtnRef.current && closeBtnRef.current.focus(); } catch (e) {}
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const onKey = (e) => {
      if (e.key === "Escape") { onClose(); return; }
      if (e.key === "Tab" && e.shiftKey && document.activeElement === closeBtnRef.current) e.preventDefault();
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.style.overflow = prevOverflow;
      try { previouslyFocused && previouslyFocused.focus && previouslyFocused.focus(); } catch (e) {}
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  return window.ReactDOM.createPortal((
    <div className={"lum-modal-backdrop" + (fullscreen ? " lum-modal-backdrop--fullscreen" : "")}
         onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className={"lum-modal-shell" + (fullscreen ? " lum-modal-shell--fullscreen" : "")}
           role="dialog" aria-modal="true" aria-label={title}>
        <button ref={closeBtnRef} className="lum-modal-close" type="button" onClick={onClose} aria-label="Close">×</button>
        <iframe src={src} title={title} className="lum-modal-iframe" loading="lazy"></iframe>
      </div>
    </div>
  ), document.body);
}
if (typeof window !== "undefined") window.LuminaraModal = LuminaraModal;

function MediaColumn({ media, locale, sources, mapCta, topicKey, mediaKey, scene, api, resetKey, premiumItems = [], showPremium = false, freeMediaOnly = false }) {
  const vidKey = String(resetKey || "") + ":" + locale;
  const vis = computeMediaVisibility({ media, locale, sources, topicKey, mediaKey, mapCta });
  const [demoOpen, setDemoOpen] = useState_r(false);
  const demoTriggerRef = useRef_r(null);
  const [srcOpen, setSrcOpen] = useState_r(false);
  useEffect_r(() => { setSrcOpen(false); }, [resetKey, locale]);
  // Demo files are self-contained single files (no per-language variant like maps) — theme/accent/
  // lang go through the URL, same convention as EMBED-MAP's buildMapSrc in foundations.jsx.
  function buildDemoSrc() {
    let theme = "dark", accent = "";
    try {
      const root = document.documentElement;
      theme = root.getAttribute("data-theme") === "light" ? "light" : "dark";
      accent = getComputedStyle(root).getPropertyValue("--accent").trim();
    } catch (e) {}
    const qs = "?theme=" + theme + "&lang=" + encodeURIComponent(locale || "ru") + (accent ? "&accent=" + encodeURIComponent(accent) : "");
    return "/maps/luminara-demo-" + topicKey + ".html" + qs;
  }
  const [premiumOpen, setPremiumOpen] = useState_r(null);
  const [premiumOpening, setPremiumOpening] = useState_r(null);
  const [premiumError, setPremiumError] = useState_r(false);
  const premiumTriggerRef = useRef_r(null);
  function buildPremiumSrc(item) {
    let theme = "dark", accent = "";
    try {
      const root = document.documentElement;
      theme = root.getAttribute("data-theme") === "light" ? "light" : "dark";
      accent = getComputedStyle(root).getPropertyValue("--accent").trim();
    } catch (e) {}
    const qs = "?theme=" + theme + "&lang=" + encodeURIComponent(locale || "ru")
      + (accent ? "&accent=" + encodeURIComponent(accent) : "");
    return "/api/v1/content/interactive/" + encodeURIComponent(item.key) + "/"
      + encodeURIComponent(item.asset) + qs;
  }
  const premiumLabels = {
    simulation: { en: "Simulation", ru: "Симуляция", uk: "Симуляція", kk: "Симуляция", uz: "Simulyatsiya", es: "Simulación", fr: "Simulation", hy: "Մոդելավորում" },
    scrolly_reader: { en: "Scroll story", ru: "История с прокруткой", uk: "Історія з прокруткою", kk: "Скролл оқиғасы", uz: "Skroll-hikoya", es: "Historia con desplazamiento", fr: "Récit défilant", hy: "Ոլորման պատմություն" },
    advanced_quiz: { en: "Advanced quiz", ru: "Углублённый квиз", uk: "Поглиблений квіз", kk: "Тереңдетілген квиз", uz: "Chuqur viktorina", es: "Cuestionario avanzado", fr: "Quiz approfondi", hy: "Խորացված քվիզ" },
    timeline: { en: "Interactive timeline", ru: "Интерактивная хронология", uk: "Інтерактивна хронологія", kk: "Интерактивті хронология", uz: "Interaktiv xronologiya", es: "Cronología interactiva", fr: "Chronologie interactive", hy: "Ինտերակտիվ ժամանակագիծ" },
  };
  const premiumSub = { en: "Available with subscription", ru: "По подписке", uk: "За підпискою", kk: "Жазылым бойынша", uz: "Obuna orqali", es: "Con suscripción", fr: "Avec abonnement", hy: "Բաժանորդագրությամբ" };
  const premiumFreeSub = { en: "Open interactive", ru: "Открыть интерактив", uk: "Відкрити інтерактив", kk: "Интерактивті ашу", uz: "Interaktivni ochish", es: "Abrir interactivo", fr: "Ouvrir l’interactif", hy: "Բացել ինտերակտիվը" };
  const liveDemoTitle = {
    preinternet: { en: "Pre-Internet", ru: "До интернета" }, web1: { en: "Web1", ru: "Web1" }, web2: { en: "Web2", ru: "Web2" }, web3: { en: "Web3", ru: "Web3" },
    bitcoin: { en: "Bitcoin", ru: "Bitcoin" }, economy: { en: "Economy", ru: "Экономика" }, netocracy: { en: "Netocracy", ru: "Нетократия" }, social: { en: "Social networks", ru: "Социальные сети" },
  };
  const premiumSessionError = { en: "Could not renew the secure session. Please refresh the page and try again.", ru: "Не удалось обновить защищённую сессию. Обновите страницу и попробуйте снова.", uk: "Не вдалося оновити захищену сесію. Оновіть сторінку та спробуйте ще раз.", kk: "Қорғалған сеансты жаңарту мүмкін болмады. Бетті жаңартып, қайта көріңіз.", uz: "Himoyalangan sessiyani yangilab bo'lmadi. Sahifani yangilab, qayta urinib ko'ring.", es: "No se pudo renovar la sesión segura. Actualiza la página e inténtalo de nuevo.", fr: "Impossible de renouveler la session sécurisée. Actualisez la page et réessayez.", hy: "Չհաջողվեց թարմացնել պաշտպանված աշխատաշրջանը։ Թարմացրեք էջը և նորից փորձեք։" };
  const RU = {
    video: { en: "Video", ru: "Видео", uk: "Відео", kk: "Бейне", uz: "Video", es: "Video", fr: "Vidéo", hy: "Տեսանյութ" },
    audio: { en: "Audio", ru: "Аудио", uk: "Аудіо", kk: "Аудио", uz: "Audio", es: "Audio", fr: "Audio", hy: "Աուդիո" },
    files: { en: "Materials", ru: "Материалы", uk: "Матеріали", kk: "Материалдар", uz: "Materiallar", es: "Materiales", fr: "Documents", hy: "Նյութեր" },
    sources: { en: "Sources", ru: "Источники", uk: "Джерела", kk: "Дереккөздер", uz: "Manbalar", es: "Fuentes", fr: "Sources", hy: "Աղբյուրներ" },
    liveDemo: { en: "Live demo", ru: "Live демо", uk: "Live демо", kk: "Live демо", uz: "Live demo", es: "Demo en vivo", fr: "Démo en direct", hy: "Կենդանի ցուցադրություն" },
    liveDemoSub: { en: "Try it yourself", ru: "Попробуй сам", uk: "Спробуй сам", kk: "Өзің байқап көр", uz: "Sinab ko'r", es: "Pruébalo tú mismo", fr: "Essaie par toi-même", hy: "Փորձիր ինքդ" },
  };
  const T = (o) => o[locale] || o.en;
  const itemTitle = (item) => typeof item.title === "string" ? item.title : (item.title ? T(item.title) : "");
  async function openPremium(item) {
    if (premiumOpening || !api || !topicKey || !Number.isInteger(scene)) return;
    setPremiumOpening(item.key);
    setPremiumError(false);
    try {
      // The iframe cannot carry the in-memory JWT. Refresh the short-lived, path-scoped
      // media cookie immediately before navigation, rather than relying on the ticket
      // issued when this scene originally mounted (it expires after ten minutes).
      const data = await api.content.interactives(topicKey, scene, locale);
      const freshItem = Array.isArray(data && data.items) && data.items.find((candidate) => candidate.key === item.key);
      if (!freshItem) throw new Error("premium_media_not_available");
      setPremiumOpen(freshItem);
    } catch (e) {
      // Never navigate the iframe to a raw 401 JSON response.
      setPremiumError(true);
    } finally {
      setPremiumOpening(null);
    }
  }
  // In Pre-Internet's free tier the agreed media surface contains only the chapter map and
  // live demo. Do not leave empty video/audio/material/source chrome around unavailable content.
  const showSupportingMedia = !freeMediaOnly;
  if (!mapCta && !vis.liveDemoOn && !(showPremium && premiumItems.length) && !(showSupportingMedia && vis.hasAny)) return null;
  return (
    <>
      {showSupportingMedia && vis.tableVideoId ? <YouTubeEmbed id={vis.tableVideoId} title={T(RU.video)} resetKey={vidKey} /> : null}
      {showSupportingMedia ? vis.video.map((v, i) => <YouTubeEmbed key={i} id={v.id} title={titleTextR(v.title, locale)} resetKey={vidKey} />) : null}

      {showSupportingMedia && vis.hasAudio ? (
        <div className="rs-media-group" data-kind="audio">
          <div className="rs-media-label"><span className="rs-media-ic">♪</span>{T(RU.audio)}</div>
        </div>
      ) : null}
      {showSupportingMedia ? vis.audio.map((a, i) => (
        <a key={i} className="rs-media-link" href={a.href} target="_blank" rel="noopener noreferrer">
          <span className="rs-media-ic small">♪</span><span>{titleTextR(a.title, locale) || T(RU.audio)}</span><span className="rs-media-ext">↗</span>
        </a>
      )) : null}

      {/* Files / materials — OR the chapter-map CTA when provided (map replaces "Материалы") */}
      {mapCta ? mapCta : (showSupportingMedia && vis.files.length ? (
        <>
          <div className="rs-media-group" data-kind="files">
            <div className="rs-media-label"><span className="rs-media-ic">↓</span>{T(RU.files)}</div>
          </div>
          {vis.files.map((f, i) => (
            <a key={i} className="rs-media-link" href={f.url} target="_blank" rel="noopener noreferrer">
              <span className="rs-media-ft">{(f.kind || "PDF").toUpperCase()}</span>
              <span>{titleTextR(f.title, locale) || T(RU.files)}</span>
              {f.size ? <span className="rs-media-ext">{f.size}</span> : <span className="rs-media-ext">↓</span>}
            </a>
          ))}
        </>
      ) : null)}

      {/* LIVE-DEMO: active+clickable only when the flag is on AND the topic maps to a real,
          supported demo file (KNOWN_LIVE_DEMO_TOPICS); otherwise this row does not exist. */}
      {vis.liveDemoOn ? (
        <button className="chapter-map-cta" type="button" ref={demoTriggerRef} onClick={() => setDemoOpen(true)}>
          <span className="cmc-icon">⚡</span>
          <span className="cmc-text">
            <b>{T(RU.liveDemo)}</b>
            <small>{T(liveDemoTitle[topicKey] || { en: topicKey || "" })}</small>
          </span>
        </button>
      ) : null}
      {demoOpen ? (
        <LuminaraModal
          title={T(RU.liveDemo)}
          src={buildDemoSrc()}
          onClose={() => setDemoOpen(false)}
          returnFocusRef={demoTriggerRef}
        />
      ) : null}

      {showPremium ? premiumItems.map((item) => (
        <button className="chapter-map-cta" type="button" key={item.key} ref={premiumTriggerRef}
                onClick={() => { void openPremium(item); }} disabled={premiumOpening === item.key}>
          <span className="cmc-icon">{({ simulation:"◌", scrolly_reader:"📜", advanced_quiz:"✦", timeline:"◷" }[item.type]) || "✦"}</span>
          <span className="cmc-text"><b>{T(premiumLabels[item.type] || { en: item.type })}</b><small>{itemTitle(item) || T(item.tier === "free" ? premiumFreeSub : premiumSub)}</small></span>
        </button>
      )) : null}
      {premiumError ? <p className="rs-media-error" role="status">{T(premiumSessionError)}</p> : null}
      {premiumOpen ? (
        <LuminaraModal title={itemTitle(premiumOpen) || T(premiumLabels[premiumOpen.type] || { en: premiumOpen.type })}
          src={buildPremiumSrc(premiumOpen)} onClose={() => setPremiumOpen(null)} returnFocusRef={premiumTriggerRef} />
      ) : null}

      {/* Sources (SOURCES-BLOCK): expandable, only rendered when at least one is real */}
      {showSupportingMedia && vis.hasSources ? (
        <div className="rs-media-group rs-media-clickable" data-kind="sources"
             role="button" tabIndex={0}
             aria-expanded={srcOpen}
             onClick={() => setSrcOpen((o) => !o)}
             onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSrcOpen((o) => !o); } }}
             style={{ cursor: "pointer" }}>
          <div className="rs-media-label">
            <span className="rs-media-ic">⌕</span>{T(RU.sources)}
            <span className={"rs-media-chev" + (srcOpen ? " open" : "")}>▸</span>
          </div>
        </div>
      ) : null}
      {showSupportingMedia && srcOpen ? (
        <SourceList sources={vis.srcs} locale={locale} variant="media" />
      ) : null}
    </>
  );
}

// Per-subtopic insight field. Saves to the journal/insights API when authenticated;
// in demo it just shows a local "saved" acknowledgement (no fake network success).
function InsightField({ topic, idx, locale, api, demo }) {
  const [text, setText] = useState_r("");
  const [saved, setSaved] = useState_r(false);
  const [saveError, setSaveError] = useState_r("");
  const activation = (typeof window !== "undefined") ? window.LUMINARA_ACTIVATION : null;
  const activationLocked = !demo && activation && !activation.activated;
  const canSave = text.trim().length > 0 && !activationLocked;
  const save = () => {
    if (!canSave) return;
    if (!demo && api && api.insights && api.insights.create) {
      // create(topic, body): the server stores { topic, body } — pass the text as body
      // (the previous single-object call left body empty, so insights never saved).
      api.insights.create(topic, text.trim()).then(() => setSaved(true)).catch((e) => {
        setSaveError(e && e.status === 423
          ? (locale === "ru" ? "Заверши сцену, квиз и подключи TON-кошелёк." : "Complete a scene, quiz and TON wallet first.")
          : (locale === "ru" ? "Не удалось сохранить." : "Could not save."));
      });
    } else {
      setSaved(true); // demo: local ack, honest (no claim it was sent)
    }
  };
  return (
    <div className="rs-insight">
      <div className="rs-insight-h">✦ {L(RUI.insightsH, locale)}</div>
      <textarea className="rs-insight-ta" rows="3"
                disabled={!!activationLocked}
                placeholder={L(RUI.insightPh, locale)}
                value={text}
                onChange={(e) => { setText(e.target.value); setSaved(false); }} />
      <div className="rs-insight-foot">
        {saved
          ? <span className="rs-insight-saved">{L(RUI.insightSaved, locale)}</span>
          : <button className="btn" disabled={!canSave} style={{ opacity: canSave ? 1 : 0.4 }} onClick={save}>{L(RUI.insightSave, locale)}</button>}
      </div>
      {(activationLocked || saveError) && <div className="acc-lock-note">🔒 {saveError || (locale === "ru" ? "Инсайты откроются после активации." : "Insights unlock after activation.")}</div>}
    </div>
  );
}

window.Research = Research;
// FOUNDATIONS-SHARE+INSIGHT (30.06): expose reader sub-components so Foundations (which
// loads before this file but renders after) can reuse the exact same share / insight /
// media blocks — единая структура на всех страницах.
window.ShareRow = ShareRow;
window.InsightField = InsightField;
window.MediaColumn = MediaColumn;

// ════════════════════════════════════════════════════════════════════
// White Paper course (F2) — special multi-section theme inside Research.
// Index: 52 modules as a numbered list. Reader: 6 sections per module
// (RU intro · EN original · translation · interpretation · 2026 status · question).
// ════════════════════════════════════════════════════════════════════
function WhitePaper({ t, locale, wp, onAtlas, onBack, authReady, authRevision }) {
  const [idx, setIdx] = useState_r(null);   // open module index, or null = list
  const eco = wp.ecosystem || {};

  // B2: WhitePaper is a paid section (week 1 = free teaser). Bodies come from the
  // backend, which decides access. The catalog also falls back to the backend list
  // once Directus locks public read on lum_whitepaper (loaded wp.modules empty).
  const wpApi = (typeof window !== "undefined") ? window.LUMINARA_API : null;
  const wpDemo = (typeof window !== "undefined" && window.LUMINARA_DEMO !== false);
  const loadedModules = wp.modules || [];
  const [remoteWeeks, setRemoteWeeks] = useState_r(null);
  const modules = loadedModules.length ? loadedModules : (remoteWeeks || []);

  useEffect_r(() => {
    if (loadedModules.length || wpDemo || !wpApi || !wpApi.content) return;
    let alive = true;
    wpApi.content.weeks()
      .then(r => { if (alive && r) setRemoteWeeks((r.weeks || []).map(w => ({ ...w, __meta: true }))); })
      .catch(() => {});
    return () => { alive = false; };
  }, [loadedModules.length]);

  const m0 = idx === null ? null : modules[idx];
  const needFetch = !!(m0 && !wpDemo && wpApi && wpApi.content &&
    (m0.__meta || Number(m0.n) > 1 || !(Array.isArray(m0.parts) && m0.parts.length)));
  const { gate, retry: retryGate } = useGatedContent({
    api: wpApi, fetchKey: m0 ? "week:" + m0.n : "week:none", gated: needFetch,
    demo: wpDemo, authReady, authRevision,
    fetcher: (a) => a.content.week(m0.n),
    pick: (r) => (r && r.week ? { week: r.week } : null),
  });

  // ── module reader ──
  if (idx !== null && modules[idx]) {
    const m = (gate && gate.week) ? gate.week : modules[idx];
    const wpLocked = !!(gate && gate.locked);
    const wpLoading = !!(gate && gate.loading);
    const wpError = !!(gate && gate.error);
    const api = wpApi;
    const demo = wpDemo;
    const Prose = window.SceneProse;
    const prose = (text) => (Prose ? <Prose text={text} /> : <p className="rs-wp-sec-body">{text}</p>);
    // Section card: mono header + body. variant "en" keeps the original verbatim (no
    // markdown, bold mono "стих"); other variants go through SceneProse (markdown).
    const Sec = (key, label, text, variant) => {
      const body = (text || "").trim();
      if (!body) return null;
      return (
        <section className={"rs-wp-sec" + (variant ? " " + variant : "")} key={key}>
          <div className="rs-wp-sec-h">{label}</div>
          {variant === "en"
            ? <p className="rs-wp-sec-body">{body}</p>
            : <div className="rs-wp-sec-body">{prose(body)}</div>}
        </section>
      );
    };

    // RICH (weekly, parts[]) vs LEGACY (flat 6-field / inline) — decided by parts[].
    const parts = Array.isArray(m.parts) ? m.parts : [];
    const isRich = parts.length > 0;
    const ctx = L(m.context2026, locale);
    const q = L(m.question, locale);
    const sources = Array.isArray(m.sources) ? m.sources : [];
    const sourceModel = (typeof window !== "undefined" && window.LuminaraSources) || null;
    const hasMediaSources = sourceModel ? sourceModel.usableSources(sources).length > 0 : sources.length > 0;
    const links = Array.isArray(m.atlas_links) ? m.atlas_links : [];
    const moveWeek = (next) => {
      setIdx(Math.max(0, Math.min(modules.length - 1, next)));
      // C2: in-place update, focus only, no outer-document scroll (see foundations moveScene).
      requestAnimationFrame(() => requestAnimationFrame(() => {
        const el = document.querySelector('.rs-subview[data-c="whitepaper"] .rs-subhead');
        if (!el) return;
        if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
        try { const st = window.getComputedStyle(el); if (/(auto|scroll)/.test(st.overflowY)) el.scrollTop = 0; } catch (e) {}
        try { el.focus({ preventScroll: true }); } catch (e) {}
      }));
    };

    // Atlas links as real navigation: week → jump to that module; topic → focus atlas.
    const goLink = (lk) => {
      if (!lk) return;
      if (lk.kind === "week") {
        const j = modules.findIndex(x => Number(x.n) === Number(lk.ref));
        if (j >= 0) moveWeek(j);
      } else if (lk.kind === "topic" && typeof onAtlas === "function") {
        onAtlas(lk.ref);
      }
    };

    const legacy = (
      <div className="rs-wp-sections">
        {Sec("wpRu", L(RUI.wpRu, locale), L(m.ru, locale))}
        {Sec("wpEn", L(RUI.wpEn, locale), L(m.en, locale), "en")}
        {Sec("wpTr", L(RUI.wpTr, locale), L(m.translation, locale))}
        {Sec("wpIn", L(RUI.wpIn, locale), L(m.interpretation, locale))}
        {Sec("wpStatus", L(RUI.wpStatus, locale), L(m.status2026, locale))}
        {Sec("wpQ", L(RUI.wpQ, locale), q, "q")}
      </div>
    );

    const rich = (
      <div className="rs-wp-sections">
        {ctx.trim() ? (
          <section className="rs-wp-sec ctx">
            <div className="rs-wp-sec-h">{L(RUI.wpContext, locale)}</div>
            <div className="rs-wp-sec-body">{prose(ctx)}</div>
          </section>
        ) : null}

        {parts.map((p, pi) => {
          const head = L(p.heading, locale);
          return (
            <div className="rs-wp-part" key={"part" + pi}>
              {head.trim() ? <div className="rs-wp-part-h">{head}</div> : null}
              {Sec("en" + pi, L(RUI.wpEn, locale) + (p.section_ref ? " · " + p.section_ref : ""), p.original_en, "en")}
              {Sec("tr" + pi, L(RUI.wpTr, locale), L(p.translation, locale))}
              {Sec("in" + pi, L(RUI.wpIn, locale), L(p.interpretation, locale))}
              {Sec("view" + pi, L(RUI.wpView, locale), L(p.view2026, locale))}
            </div>
          );
        })}

        {Sec("wpQ", L(RUI.wpQ, locale), q, "q")}

        {links.length ? (
          <section className="rs-wp-sec links">
            <div className="rs-wp-sec-h">{L(RUI.wpLinks, locale)}</div>
            <div className="rs-wp-linklist">
              {links.map((lk, li) => (
                <button className="rs-wp-link" key={li} onClick={() => goLink(lk)}>→ {L(lk.label, locale) || lk.ref}</button>
              ))}
            </div>
          </section>
        ) : null}

        {/* CONTENT-INSIGHT-WP: reader can leave their insight at the bottom of each week */}
        <InsightField topic={m.node_id || "whitepaper"} idx={m.n} locale={locale} api={api} demo={demo} />
      </div>
    );

    const hasAny = isRich
      ? (ctx.trim() || q.trim() || parts.some(p =>
          (p.original_en || "").trim() || L(p.translation, locale).trim() ||
          L(p.interpretation, locale).trim() || L(p.view2026, locale).trim()) || hasMediaSources)
      : ([m.ru, m.en, m.translation, m.interpretation, m.status2026, m.question].some(v => L(v, locale).trim()) || hasMediaSources);

    return (
      <div className="section-pad fade-in research rs-subview" data-c="whitepaper">
        <button className="eco-back" onClick={() => setIdx(null)}><span>←</span> {L(RUI.backCourse, locale)}</button>
        <div className="rs-subhead" data-c="whitepaper">
          <div className="rs-thead-eco">{titleTextR(eco.title, locale) || "White Paper"} · {RESEARCH_ECO.name}</div>
          <div className="rs-subhead-row">
            <span className="rs-sub-n big">{String(m.n).padStart(2, "0")}</span>
            <h2>{L(m.title, locale)}</h2>
          </div>
          {m.section ? <div className="rs-wp-section-ref">{m.section}</div> : null}
        </div>

        <ResearchPager t={t} index={idx} total={modules.length}
                       onPrev={idx > 0 ? () => moveWeek(idx - 1) : null}
                       onNext={idx < modules.length - 1 ? () => moveWeek(idx + 1) : null}
                       placement="top" />

        {wpLocked ? <Paywall locale={locale} />
          : wpError ? <GateError locale={locale} onRetry={retryGate} />
          : wpLoading ? <p className="rs-text-body rs-text-loading">…</p>
          : (!hasAny ? <div className="uni-empty">{L(RUI.wpEmpty, locale)}</div> : (
            <div className="rs-body-grid">
              {/* The White Paper uses the same media square as Foundations,
                  AI, Trading, TON and every other Research reader. */}
              {hasMediaSources ? (
                <aside className="rs-media">
                  <div className="rs-media-h">{L(RUI.media, locale)}</div>
                  <MediaColumn media={null} locale={locale} topicKey="whitepaper"
                    resetKey={"whitepaper:" + m.n} sources={sources} />
                </aside>
              ) : null}
              <div className="rs-text">{isRich ? rich : legacy}</div>
            </div>
          ))}

        <ResearchPager t={t} index={idx} total={modules.length}
                       onPrev={idx > 0 ? () => moveWeek(idx - 1) : null}
                       onNext={idx < modules.length - 1 ? () => moveWeek(idx + 1) : null} />
      </div>
    );
  }

  // ── module list ──
  const ready = (m) => {
    if (Array.isArray(m.parts) && m.parts.length &&
        m.parts.some(p => (p.original_en || "").trim() || L(p.interpretation, locale).trim())) return true;
    return [m.ru, m.en, m.translation, m.interpretation, m.status2026, m.question].some(v => L(v, locale).trim());
  };
  return (
    <div className="section-pad fade-in research" data-c="whitepaper">
      <button className="eco-back" onClick={onBack}><span>←</span> {L(RUI.backAll, locale)}</button>
      <div className="rs-thead" data-c="whitepaper">
        <div className="rs-thead-eco">{RESEARCH_ECO.name} · {L(RUI.special, locale)}</div>
        <span className="rs-chip">{L(RUI.course, locale)} · {modules.length} {L(RUI.modules, locale)}</span>
        <h2>{titleTextR(eco.title, locale) || "White Paper"}</h2>
        <p>{L(eco.blurb, locale)}</p>
      </div>
      <div className="rs-section-h">{modules.length} {L(RUI.modules, locale)} · {L(RUI.perWeek, locale)}</div>
      <div className="rs-sublist">
        {modules.map((m, i) => {
          const isMeta = !!m.__meta;
          const clickable = isMeta ? true : ready(m);
          const wkLocked = isMeta && m.locked;
          return (
            <button className={"rs-sub" + (clickable ? "" : " rs-sub-soon")} key={i}
                    data-c="whitepaper" onClick={() => setIdx(i)}>
              <span className="rs-sub-n">{String(m.n).padStart(2, "0")}</span>
              <span className="rs-sub-label">{L(m.title, locale)}</span>
              {wkLocked ? <span className="rs-sub-lock" aria-hidden="true">🔒</span>
                : clickable ? <span className="rs-sub-go">→</span>
                : <span className="rs-media-soon">{L(RUI.soon, locale)}</span>}
            </button>
          );
        })}
      </div>
    </div>
  );
}

window.WhitePaper = WhitePaper;

// ════════════════════════════════════════════════════════════════════
// «Моя вселенная» (My Universe) — B2
// A personal concept-map: nodes = themes the user has read; edges connect
// themes that SHARE a concept (a scene tag that appears in both). This is the
// "soul-bound is in both TON and ETH → linked into one map" idea: the same
// concept surfacing across blockchains stitches the user's themes together.
// Data: lesson_progress (read state) + EXTERNAL[topic].scenes[].tags.
// Rendering: self-contained SVG (no three.js dependency), radial layout.
// ════════════════════════════════════════════════════════════════════

const UUI = {
  title:    { en: "My Universe", ru: "Моя вселенная", uk: "Мій всесвіт", kk: "Менің әлемім", uz: "Mening olamim", es: "Mi universo", fr: "Mon univers", hy: "Իմ տիեզերքը" },
  sub:      { en: "Themes you've explored, linked by shared concepts.", ru: "Изученные темы, связанные общими концепциями.", uk: "Вивчені теми, поєднані спільними концепціями.", kk: "Ортақ тұжырымдамалармен байланысқан зерттелген тақырыптар.", uz: "Umumiy tushunchalar bilan bog'langan o'rganilgan mavzular.", es: "Los temas que has explorado, conectados por conceptos en común.", fr: "Les thèmes que tu as explorés, reliés par des concepts communs.", hy: "Թեմաները, որ ուսումնասիրել ես՝ կապված ընդհանուր հասկացություններով։" },
  empty:    { en: "Start reading — explored themes appear here and link up automatically.", ru: "Начните читать — изученные темы появятся здесь и свяжутся автоматически.", uk: "Почніть читати — вивчені теми з'являться тут і з'єднаються автоматично.", kk: "Оқуды бастаңыз — зерттелген тақырыптар осында пайда болып, автоматты түрде байланысады.", uz: "O'qishni boshlang — o'rganilgan mavzular shu yerda paydo bo'lib, avtomatik bog'lanadi.", es: "Empieza a leer: los temas explorados aparecen aquí y se conectan automáticamente.", fr: "Commence à lire — les thèmes explorés apparaissent ici et se relient automatiquement.", hy: "Սկսիր կարդալ՝ ուսումնասիրած թեմաները հայտնվում են այստեղ և ինքնաշխատ կապվում։" },
  themesN:  { en: "themes", ru: "тем", uk: "тем", kk: "тақырып", uz: "mavzu", es: "temas", fr: "thèmes", hy: "թեմա" },
  linksN:   { en: "links", ru: "связей", uk: "зв'язків", kk: "байланыс", uz: "bog'lanish", es: "enlaces", fr: "liens", hy: "կապ" },
  conceptsN:{ en: "concepts", ru: "концепций", uk: "концепцій", kk: "тұжырымдама", uz: "tushuncha", es: "conceptos", fr: "concepts", hy: "հասկացություն" },
  open:     { en: "Open My Universe", ru: "Открыть мою вселенную", uk: "Відкрити мій всесвіт", kk: "Менің әлемімді ашу", uz: "Mening olamimni ochish", es: "Abrir Mi universo", fr: "Ouvrir Mon univers", hy: "Բացել Իմ տիեզերքը" },
  back:     { en: "Back to account", ru: "Назад в кабинет", uk: "Назад до кабінету", kk: "Кабинетке қайту", uz: "Kabinetga qaytish", es: "Volver a la cuenta", fr: "Retour au compte", hy: "Վերադառնալ հաշվին" },
  shared:   { en: "Shared concepts", ru: "Общие концепции", uk: "Спільні концепції", kk: "Ортақ тұжырымдамалар", uz: "Umumiy tushunchalar", es: "Conceptos en común", fr: "Concepts communs", hy: "Ընդհանուր հասկացություններ" },
};

// Scene list for a topic: loaded data, else backend-sourced paid scenes (B2). Shared
// by buildUniverse (below), which lives outside Research and can't see its state.
function sceneListFor(id) {
  const D = window.LUMINARA_DATA || {};
  const ex = D.EXTERNAL || {};
  if (ex[id] && ex[id].scenes && ex[id].scenes.length) return ex[id].scenes;
  const rem = window.LUMINARA_REMOTE_SCENES || {};
  return rem[id] || [];
}

// Build the personal map from studied themes plus their direct canonical neighbours.
// Neighbour metadata is already public; lesson access remains enforced by the reader.
function buildUniverse(readSet, progressData) {
  const D = window.LUMINARA_DATA || {};
  const ecosystems = D.ECOSYSTEMS || [];
  const readTopics = new Set();
  readSet.forEach(key => { const tid = key.split(":")[0]; if (sceneListFor(tid).length) readTopics.add(tid); });
  Object.keys((progressData && progressData.byTopic) || {}).forEach((tid) => {
    if (sceneListFor(tid).length) readTopics.add(tid);
  });

  const candidateIds = new Set(Object.keys(D.EXTERNAL || {}).filter(id => sceneListFor(id).length));
  ecosystems.forEach(e => { if (sceneListFor(e.id).length) candidateIds.add(e.id); });
  const included = new Set(readTopics);
  (D.EDGES || []).forEach((edge) => {
    const a = Array.isArray(edge) ? edge[0] : edge && (edge.a || edge.from);
    const b = Array.isArray(edge) ? edge[1] : edge && (edge.b || edge.to);
    if (readTopics.has(a) && candidateIds.has(b)) included.add(b);
    if (readTopics.has(b) && candidateIds.has(a)) included.add(a);
  });
  // Some Foundations chapters are intentionally outside the atlas edge list. Their
  // authored tags are still canonical metadata, so expose up to three closest 1-hop
  // chapters instead of leaving an opened chapter isolated.
  const tagsOf = (id) => {
    const tags = new Set();
    sceneListFor(id).forEach(s => (s.tags || []).forEach(t => tags.add(String(t).toLowerCase())));
    return tags;
  };
  readTopics.forEach((seed) => {
    const seedTags = tagsOf(seed);
    if (!seedTags.size) return;
    [...candidateIds].filter(id => id !== seed && !readTopics.has(id)).map(id => {
      const score = [...tagsOf(id)].filter(tag => seedTags.has(tag)).length;
      return { id, score };
    }).filter(x => x.score > 0).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
      .slice(0, 3).forEach(x => included.add(x.id));
  });

  const nodes = [...included].map(id => {
    const eco = ecosystems.find(e => e.id === id);
    const atlasNode = (D.NODES || []).find(n => n.id === id);
    const scenes = sceneListFor(id);
    const tags = new Set();
    scenes.forEach(s => (s.tags || []).forEach(tg => tags.add(String(tg).toLowerCase())));
    const cursor = progressData && progressData.byTopic && progressData.byTopic[id];
    const serverState = window.LuminaraProgress.topicState(id, scenes, scenes.length, cursor);
    const localRead = scenes.reduce((count, scene, index) => {
      const stableKey = window.LuminaraProgress.sceneKey(id, scene, index);
      return count + (readSet.has(`${id}:ck:${stableKey}`) || readSet.has(`${id}:${index}`) ? 1 : 0);
    }, 0);
    const learned = Math.max(localRead, serverState.completedCount);
    const completed = serverState.completed || learned >= scenes.length;
    const quiz = progressData && progressData.quizByTopic && progressData.quizByTopic[id];
    const quizAvailable = ((window.LUMINARA_QUIZ_TOPICS || []).includes(id));
    return {
      id, title: eco ? (eco.titleMl || eco.title) : (atlasNode ? atlasNode.title : id), tags, readCount: learned, total: scenes.length,
      status: completed ? "completed" : (learned > 0 ? "in_progress" : "not_started"),
      progressPct: scenes.length ? Math.round((learned / scenes.length) * 100) : 0,
      quizPct: quiz && Number.isFinite(quiz.score_pct) ? quiz.score_pct : null,
      quizAvailable,
      related: !readTopics.has(id),
    };
  });

  // edges: two themes linked if they share >=1 concept (tag)
  const edges = [];
  const conceptSet = new Set();
  for (let i = 0; i < nodes.length; i++) {
    for (let j = i + 1; j < nodes.length; j++) {
      const shared = [...nodes[i].tags].filter(tg => nodes[j].tags.has(tg));
      if (shared.length) {
        edges.push({ a: nodes[i].id, b: nodes[j].id, shared, weight: shared.length });
        shared.forEach(c => conceptSet.add(c));
      }
    }
  }
  // Preserve canonical atlas links even when the two topics have no shared tag.
  const edgeKeys = new Set(edges.map(e => [e.a, e.b].sort().join("|")));
  (D.EDGES || []).forEach((edge) => {
    const a = Array.isArray(edge) ? edge[0] : edge && (edge.a || edge.from);
    const b = Array.isArray(edge) ? edge[1] : edge && (edge.b || edge.to);
    if (!included.has(a) || !included.has(b)) return;
    const key = [a, b].sort().join("|");
    if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ a, b, shared: [], weight: 1, canonical: true }); }
  });
  return { nodes, edges, concepts: [...conceptSet] };
}

function colorFor(id) {
  const map = { ton: "--c-ton", ton_new_internet: "--c-ton", ton_vs_eth: "--c-compare",
    wallet: "--c-wallet", miniapps: "--c-miniapps", payments: "--c-payments",
    communities: "--c-community", social_graph: "--c-telegram", gamefi: "--c-game",
    tokens: "--c-token", ethereum: "--c-eth", bitcoin: "--c-btc", rwa: "--c-rwa" };
  return `var(${map[id] || "--accent"})`;
}

// SVG radial graph. Nodes on a circle; edges drawn between them; hovering/clicking
// an edge reveals the shared concept(s).
function UniverseGraph({ data, locale, compact }) {
  const { nodes, edges } = data;
  const [sel, setSel] = useState_r(null); // selected edge index
  const W = compact ? 320 : 720, H = compact ? 260 : 520;
  const cx = W / 2, cy = H / 2;
  const R = Math.min(W, H) / 2 - (compact ? 46 : 90);
  const pos = {};
  nodes.forEach((n, i) => {
    const a = (i / Math.max(1, nodes.length)) * Math.PI * 2 - Math.PI / 2;
    pos[n.id] = { x: cx + Math.cos(a) * R, y: cy + Math.sin(a) * R };
  });
  const L2 = (o) => (o ? (o[locale] || o.en) : "");
  return (
    <svg viewBox={`0 0 ${W} ${H}`} className="uni-svg" style={{ width: "100%", height: "auto" }}>
      {/* edges */}
      {edges.map((e, i) => {
        const A = pos[e.a], B = pos[e.b];
        if (!A || !B) return null;
        const on = sel === i;
        return (
          <g key={i}>
            <line x1={A.x} y1={A.y} x2={B.x} y2={B.y}
                  stroke={on ? "var(--accent)" : "var(--line)"}
                  strokeWidth={on ? 2 : Math.min(3, 0.6 + e.weight * 0.5)}
                  strokeOpacity={on ? 1 : 0.5}
                  style={{ cursor: "pointer" }}
                  onClick={() => setSel(on ? null : i)} />
            {on && !compact && (
              <text x={(A.x + B.x) / 2} y={(A.y + B.y) / 2 - 6}
                    fill="var(--accent)" fontSize="11" textAnchor="middle"
                    fontFamily="var(--font-mono, monospace)">
                {e.shared.slice(0, 3).join(" · ")}
              </text>
            )}
          </g>
        );
      })}
      {/* nodes */}
      {nodes.map(n => {
        const P = pos[n.id];
        const r = compact ? 7 : 11;
        return (
          <g key={n.id}>
            <circle cx={P.x} cy={P.y} r={r} fill={colorFor(n.id)}
                    stroke="var(--bg)" strokeWidth="2" />
            <text x={P.x} y={P.y + r + (compact ? 11 : 14)} fill="var(--text-dim)"
                  fontSize={compact ? 9 : 11} textAnchor="middle">
              {(typeof n.title === "string" ? n.title : L2(n.title)).split(" · ")[0]}
            </text>
          </g>
        );
      })}
      {nodes.length === 0 && (
        <text x={cx} y={cy} fill="var(--text-muted)" fontSize="12" textAnchor="middle">∅</text>
      )}
    </svg>
  );
}

// Full-page My Universe (reached from the account card)
// PERSONAL-ATLAS (#75): this is the home Atlas with learner status laid over it,
// not a separately generated concept graph. Canonical coordinates, radii and edges
// keep both surfaces recognisably the same and prevent a dense label "ball".
function universeTo3D(data) {
  const D = (typeof window !== "undefined" && window.LUMINARA_DATA) || {};
  const atlasNodes = Array.isArray(D.NODES) ? D.NODES : [];
  const progressById = new Map(((data && Array.isArray(data.nodes)) ? data.nodes : [])
    .filter(nd => nd && nd.id)
    .map(nd => [nd.id, nd]));
  const nodes = atlasNodes.map((atlas) => {
    const progress = progressById.get(atlas.id);
    const active = progress && progress.status !== "not_started";
    return {
      ...atlas,
      // Untouched nodes keep the clean home-Atlas label rather than gaining a
      // noisy "0%" or an uncurated course/chapter name.
      status: progress ? progress.status : "not_started",
      progressPct: active ? progress.progressPct : undefined,
      quizPct: active ? progress.quizPct : undefined,
      quizAvailable: active ? progress.quizAvailable : false,
    };
  });
  const nodeIds = new Set(nodes.map(n => n.id));
  const edges = (Array.isArray(D.EDGES) ? D.EDGES : []).map((edge) => {
    const a = Array.isArray(edge) ? edge[0] : edge && (edge.a || edge.from);
    const b = Array.isArray(edge) ? edge[1] : edge && (edge.b || edge.to);
    return (nodeIds.has(a) && nodeIds.has(b)) ? [a, b] : null;
  }).filter(Boolean);
  return { nodes, edges };
}

function MyUniverse({ t, locale, completed, progressData, onBack }) {
  // MY-UNIVERSE-3D: render the user's STUDIED-topics graph (the same data the account-page card
  // shows) as the 3D rotating atlas. buildUniverse(readSet) → universeTo3D keeps view and card
  // consistent. (`completed` — the atlas-node set — is intentionally unused: Foundations chapters
  // aren't atlas nodes, so filtering by it would render almost empty.)
  const api = (typeof window !== "undefined") ? window.LUMINARA_API : null;
  const demo = (typeof window !== "undefined" && window.LUMINARA_DEMO !== false);
  const { readSet, progressData: localProgress } = useReadState(api, demo);
  const effectiveProgress = progressData || localProgress;
  const readKey = [...readSet].sort().join(",");
  const progressKey = JSON.stringify(effectiveProgress && effectiveProgress.byTopic || {});
  const data = React.useMemo(() => buildUniverse(readSet, effectiveProgress), [readKey, progressKey]);
  const g3d = React.useMemo(() => universeTo3D(data), [readKey, progressKey]);
  const Atlas3D = (typeof window !== "undefined") ? window.Atlas : null;
  const totalScenes = data.nodes.reduce((s, n) => s + (n.total || 0), 0);
  const readScenes = data.nodes.reduce((s, n) => s + (n.readCount || 0), 0);
  const pct = totalScenes ? Math.round((readScenes / totalScenes) * 100) : 0;
  return (
    <div className="section-pad fade-in research">
      <button className="eco-back" onClick={onBack}><span>←</span> {L(UUI.back, locale)}</button>
      <div className="section-head">
        <div className="kicker">{L(UUI.title, locale)}</div>
        <h2>{L(UUI.title, locale)}</h2>
        <p>{L(UUI.sub, locale)}</p>
      </div>
      {(!g3d.nodes.length || !Atlas3D) ? (
        <div className="uni-empty">{L(UUI.empty, locale)}</div>
      ) : (
        <div className="uni-stage-3d" style={{ height: "72vh", minHeight: 420, position: "relative" }}>
          <div className="uni-status-legend"><span className="done">● {({ru:"Пройдено",en:"Completed"})[locale] || "Completed"}</span><span className="doing">● {({ru:"В процессе",en:"In progress"})[locale] || "In progress"}</span><span className="next">● {({ru:"Связано",en:"Related"})[locale] || "Related"}</span></div>
          <Atlas3D t={t} locale={locale} nodes={g3d.nodes} edges={g3d.edges}
                   completed={new Set(g3d.nodes.filter(n => n.status === "completed").map(n => n.id))} onComplete={() => {}}
                   selected={null} onSelect={(id) => { window.location.hash = "#/research?t=" + encodeURIComponent(id) + "&s=0"; }} progressPct={pct} />
        </div>
      )}
    </div>
  );
}

// Compact card for the account page
function MyUniverseCard({ locale, onOpen }) {
  const api = (typeof window !== "undefined") ? window.LUMINARA_API : null;
  const demo = (typeof window !== "undefined" && window.LUMINARA_DEMO !== false);
  const { readSet, progressData } = useReadState(api, demo);
  const readKey = [...readSet].sort().join(",");
  const progressKey = JSON.stringify(progressData && progressData.byTopic || {});
  const data = React.useMemo(() => buildUniverse(readSet, progressData), [readKey, progressKey]);
  const g3d = React.useMemo(() => universeTo3D(data), [readKey, progressKey]);
  const Atlas3D = (typeof window !== "undefined") ? window.Atlas : null;
  return (
    <div className="acc-card acc-wide">
      <div className="acc-h acc-h-row">
        <span>✦ {L(UUI.title, locale)}</span>
        <button className="acc-missions-all" onClick={onOpen}>{L(UUI.open, locale)} →</button>
      </div>
      <div className="acc-sub" style={{ marginBottom: 12 }}>{L(UUI.sub, locale)}</div>
      {data.nodes.length === 0 ? (
        <div className="uni-empty">{L(UUI.empty, locale)}</div>
      ) : Atlas3D ? (
        <>
          {/* #75: this is deliberately the same full-size canonical Atlas as the
              home surface.  The former SVG fallback was a personal tag graph
              (42 topics / 497 links), which compressed into an unreadable ball. */}
          <div className="uni-stage-3d uni-stage-3d-card" style={{ position: "relative" }}>
            <Atlas3D locale={locale} nodes={g3d.nodes} edges={g3d.edges} chrome={false} cameraScale={0.62}
                     completed={new Set(g3d.nodes.filter(n => n.status === "completed").map(n => n.id))} onComplete={() => {}}
                     selected={null} onSelect={() => {}}
                     progressPct={data.nodes.reduce((s,n)=>s+n.total,0) ? Math.round(data.nodes.reduce((s,n)=>s+n.readCount,0) / data.nodes.reduce((s,n)=>s+n.total,0) * 100) : 0} />
          </div>
        </>
      ) : (
        <div className="uni-empty">{L(UUI.empty, locale)}</div>
      )}
    </div>
  );
}

window.MyUniverse = MyUniverse;
window.MyUniverseCard = MyUniverseCard;
