/* ============================================================================
   JARVIS SASTRE — EL ESTUDIO (MC Contenido · Fase 1, CANARY).

   Dos paneles: IZQUIERDA = el chat (JARVIS entrevista + genera, conversación limpia);
   DERECHA = SU ESTUDIO = archivo de TODO lo creado (texto Y carrusel con imágenes
   grandes), lo nuevo arriba, cada pieza con Ver/Aprobar/Pedir cambio/Copiar/Ocultar.
   El chat NO encima la pieza: la manda al estudio → la conversación queda limpia y
   el estudio conserva todo (nada se borra solo = JARVIS no parece de memoria corta).

   Diseño clave (Cerebras 5/min): la conversación es DETERMINÍSTICA (0 IA) el ~80% del
   tiempo; solo se gasta IA al generar/refinar. El pacing "procesando → anotando" es
   mayordomo Y frena el spam que revienta la cuota.

   Reusa window.MCCarruselImg (compositor de content.jsx) para las imágenes → sin duplicar.
   Auto-montaje DESACOPLADO: dos paneles en #view-content SOLO si el flag `chat` (canary).
   CSS namespace: mcx-*.
   ========================================================================== */
(function () {
  "use strict";
  const { useState, useEffect, useRef, useCallback } = React;

  // ---------- config del descubrimiento (0 IA) ----------
  // 🐛 UNA SOLA FUENTE PARA LA PREGUNTA DEL NICHO (cazado por Danny a los 2 minutos de
  //    publicar el menú). La pregunta vivía DUPLICADA: aquí y en el saludo de «marca
  //    nueva». Al poner los 10 chips solo se actualizó esta, y al crear una marca salía
  //    el menú debajo de «¿de qué trata su contenido o cuál es su nicho?» — una pregunta
  //    abierta encima de una lista cerrada. Ahora las dos comparten este texto: cambiarlo
  //    en un sitio los cambia a los dos, y la clase de fallo deja de existir.
  const PREG_NICHO = "¿En cuál de estos se mueve? Si el suyo no está, escríbalo abajo — trabajo igual, solo que en estos me manejo mejor.";

  // ⭐ LOS SUBTIPOS — cazado por Danny probando el menú: «gastronomía no quiere decir
  //    repostería, puede ser comida o las que tenemos agrupadas… lo ideal es encontrar
  //    cuál es en esa área». Tenía razón: son 10 PUERTAS pero ~17 oficios, y dos que
  //    comparten puerta NO comparten receta —
  //      repostería habla de gramos y grados · un restaurante de platos y técnica
  //      uñas habla de curado y capas   · una barbería de degradados y tijera
  //    Sin esta pregunta, el contenido sale a medio camino entre los tres.
  // 🔑 EL TRUCO: la respuesta NO se guarda aparte — REEMPLAZA el nicho por el
  //    específico. Así todo lo de abajo (los escudos, el brief, las recetas que vienen)
  //    sigue leyendo un solo campo y no hay que tocar nada más. Y como el valor nuevo ya
  //    no es una llave de este mapa, la pregunta no se repite al recargar.
  // Solo llevan subtipo las 4 familias que de verdad agrupan oficios distintos; las
  // otras 6 puertas son un oficio solo y se saltan esta pregunta.
  const SUBNICHOS = {
    "gastronomía y repostería": { q: "¿Y de qué lado, señor? Cada uno se cuenta distinto.", ops: [
      { label: "🎂 Repostería y tortas", val: "repostería artesanal — tortas, postres y decoración" },
      { label: "🍽️ Cocina y restaurante", val: "cocina y restaurante — platos y técnica de cocina" },
      { label: "🥂 Catering y eventos", val: "catering para eventos — menús por encargo" },
    ] },
    "belleza y estética": { q: "¿Y a qué se dedica exactamente, señor?", ops: [
      { label: "💅 Uñas y manicura", val: "manicura y uñas esculpidas" },
      { label: "💇 Cabello y barbería", val: "peluquería y barbería — cortes, color y afeitado" },
      { label: "💄 Maquillaje", val: "maquillaje profesional para novias y eventos" },
      { label: "✨ Estética y piel", val: "estética y cuidado de la piel — tratamientos faciales" },
    ] },
    "mascotas y veterinaria": { q: "¿Y de qué se ocupa, señor?", ops: [
      { label: "🩺 Veterinaria", val: "clínica veterinaria — salud y prevención en mascotas" },
      { label: "✂️ Peluquería canina", val: "peluquería canina y grooming" },
      { label: "🦮 Adiestramiento", val: "adiestramiento y conducta canina" },
      { label: "🛍️ Tienda de mascotas", val: "tienda de mascotas — alimento y accesorios" },
    ] },
    "salud y bienestar (entrenamiento, nutrición, hábitos)": { q: "¿Y por dónde va lo suyo, señor?", ops: [
      { label: "🏋️ Entrenamiento", val: "entrenamiento personal — fuerza, movilidad y técnica" },
      { label: "🥗 Nutrición", val: "nutrición y alimentación saludable, sin dietas locas" },
      { label: "🧘 Hábitos y descanso", val: "hábitos saludables, descanso y bienestar diario" },
    ] },
  };
  function subDe(p) { return SUBNICHOS[String((p && p.nicho) || "").trim()] || null; }
  const PREGUNTAS = [
    // ⭐⭐ EL MENÚ DE LOS 10 (27-jul). Antes esto era texto libre y JARVIS trabajaba
    //    con lo que le escribieran, sin poder ser bueno en nada en concreto. Danny:
    //    «si ponemos muchos nichos nos volveremos locos… lo ideal es tener nichos muy
    //    buenos con mucha demanda» → se eligen 10 y se es EXCELENTE en ellos.
    //    Los 10 salieron de una investigación de 3 cabezas + la medición propia: los
    //    nichos con COSAS CONCRETAS (una pieza, un gramo, un síntoma) producen buen
    //    contenido y los abstractos producen folleto — medido, mecánica 5 de 6 piezas
    //    usables contra nutrición 0 de 6. Ver [[project_mc_10_nichos]].
    // ⚠️ NO BLOQUEA a quien no esté en la lista: `free: true` deja escribir el suyo y se
    //    trabaja igual. Bloquear perdería la venta y, además, cada nicho escrito a mano
    //    es un VOTO — así sabremos cuál es el 11 por nuestros propios usuarios y no por
    //    blogs de afiliados. El servidor lo anota (ver _mcNichoFueraDeCatalogo).
    // ⚠️ El VALOR de cada chip está escrito para encender el terreno correcto (comida,
    //    cuerpo, riesgo_fisico, animales, salud, contratos, superacion): si se toca una
    //    etiqueta hay que comprobar que su terreno siga encendiendo (test-catalogo.mjs).
    // ⚠️ MEDIDO antes de simplificar: se temía que la categoría del chip diera peor
    //    contenido que el texto libre específico ("repostería artesanal por encargo").
    //    NO lo da — las dos salieron concretas y en banda ("espera 20 minutos antes de
    //    revisar" vs "verificar los 180°C"). La especificidad la aportan las otras
    //    preguntas (para quién · a dónde), así que el chip puede enviar directo.
    { key: "nicho", q: "Con gusto le preparo su contenido, señor. " + PREG_NICHO,
      chips: [
        { label: "🍰 Gastronomía", val: "gastronomía y repostería" },
        { label: "🧱 Reformas y obra", val: "reformas y construcción" },
        { label: "💅 Belleza", val: "belleza y estética" },
        { label: "🏠 Bienes raíces", val: "bienes raíces" },
        { label: "🔧 Taller mecánico", val: "taller mecánico y automotriz" },
        { label: "🐶 Mascotas", val: "mascotas y veterinaria" },
        { label: "💪 Salud y bienestar", val: "salud y bienestar (entrenamiento, nutrición, hábitos)" },
        { label: "🤖 Tech e IA", val: "tecnología e inteligencia artificial" },
        { label: "🔥 Motivación", val: "motivación y crecimiento personal" },
        { label: "📱 Marketing y redes", val: "marketing digital y redes sociales" },
      ], free: true, ph: "¿El suyo no está? Escríbalo aquí…" },
    // ⚠️ El ejemplo era "jóvenes que quieren ahorrar sin complicarse" — de FINANZAS.
    // Danny lo vio en vivo tras decir que su nicho era motivación: JARVIS acababa de
    // oírle y le sugería el público de otro rubro. En un producto que promete "te
    // conoce", eso resta. Ahora el ejemplo enseña la FORMA de la respuesta (quién es,
    // qué edad, qué le pasa) sin casarse con ningún nicho.
    // Solo aparece si la puerta elegida agrupa varios oficios (ver SUBNICHOS).
    // `escribeEn: "nicho"` → la respuesta PISA el nicho con el específico.
    { key: "sub_nicho", escribeEn: "nicho", soloSi: subDe,
      qDe: (p) => (subDe(p) || {}).q || "¿Y de qué lado, señor?",
      chipsDe: (p) => (subDe(p) || {}).ops || [], free: true, ph: "O dígamelo con sus palabras…" },
    { key: "audiencia", q: "Entendido. ¿Y para quién es? Descríbame a su público.", ph: "Ej: mujeres de 30 a 45 con poco tiempo y muchas ganas…" },
    { key: "voz", q: "¿Cómo le gusta hablarle a su gente?", chips: [{ label: "Directa" }, { label: "Cercana" }, { label: "Educativa" }, { label: "Motivadora" }], free: true, ph: "O descríbalo con sus palabras…" },
    // ⚠️ Antes solo decía "¿En qué redes publica?" y Danny —que construyó esto— dudó:
    // «¿por qué me ofrece TikTok si solo tenemos Instagram para publicar?». La pregunta
    // NO es para publicar: la respuesta entra en su brief ("- Publica en: …") y cambia
    // CÓMO se escribe cada pieza (gancho corto en TikTok, tono pro en LinkedIn…).
    // Si la duda le dio a él, le dará a cualquiera → se dice el porqué en la pregunta.
    { key: "plataformas", q: "¿En qué redes publica? Así le escribo a la medida de cada una.", chips: [{ label: "TikTok" }, { label: "Instagram" }, { label: "YouTube" }, { label: "LinkedIn" }, { label: "Facebook" }], multi: true },
    // ⚠️ FALTABA EL PUNTO MEDIO, lo vio Danny probando (28-jul): «no hay un punto medio de
    //    1:30, y eso a veces mucha gente lo busca». Y tenía razón en algo peor: el selector
    //    de duración de CADA REEL sí lo tiene (DUR_OPTS, cinco opciones), pero la entrevista
    //    de la marca solo ofrecía tres. Dos sitios que preguntan lo mismo con listas
    //    distintas — el usuario elegía «~1 minuto» porque no le dábamos su opción.
    //    Ahora las dos ofrecen lo mismo. (El servidor ya acepta 'min90': ver _mcCleanPerfilField.)
    { key: "duracion_reel", q: "¿Cuánto suelen durar sus videos? El punto dulce es menos de 2:30, señor.", chips: [{ label: "Menos de 30s", val: "corto" }, { label: "~1 minuto", val: "medio" }, { label: "~1:30 min", val: "min90" }, { label: "~2 minutos", val: "largo" }, { label: "~2:30 (máx)", val: "extendido" }] },
    // ⭐ EDUCAR VA PRIMERO — y no es cosmética: la primera opción de una lista es la que
    //   más se elige, y aquí «Educar» iba la última. Danny lo dictó el 30-jul mirando seis
    //   piezas suyas: *«la mayoría lo usa para EDUCAR… nadie lo usa para vender — bueno sí,
    //   pero es baja la tasa. Yo me enganché por querer informarme»*. Poner Vender delante
    //   empujaba al modo minoritario, y encima al ÚNICO sobre el que se calibró todo el
    //   afinado. No se quita nada: las tres siguen ahí y él elige lo que quiera.
    //   Ver [[project_mc_carrusel_educar_no_vender]].
    { key: "objetivo", q: "¿Qué busca con su contenido?", chips: [{ label: "Educar", val: "educar" }, { label: "Crecer", val: "crecer" }, { label: "Vender", val: "vender" }] },
    // ⭐ LA PREGUNTA QUE FALTABA — y la razón de que el cierre fallara la mitad de las veces.
    // Las 6 anteriores preguntan por el CONTENIDO (tema, público, tono, red, duración,
    // objetivo) y NINGUNA por su negocio. El usuario marcaba «Vender» y JARVIS no sabía
    // QUÉ → cerraba con «sígueme» de relleno. Y sin nada real a lo que agarrarse, el
    // modelo rellenaba inventando.
    // Danny: «no todos venden, otros sí y otros no» → la pregunta NACE del objetivo que
    // acaba de elegir. Las tres buscan lo mismo: darle al cierre un sitio A DÓNDE IR.
    // Es GENERAL, no de un nicho: sirve igual al pastelero, al abogado y al coach.
    // ⚠️ MEDIDO con Mistral: las 3 tienen que pedir UN DESTINO, no una idea bonita.
    // La primera versión preguntaba a los de «educar» QUÉ deben aprender — y una
    // respuesta así NO cambia el cierre: «que sean constantes» dio exactamente el
    // mismo cierre que no responder nada. Lo único que movió la aguja fue tener A
    // DÓNDE mandar a la gente. Ahora las tres piden eso, cada una con sus palabras.
    { key: "destino", qPorObjetivo: {
        vender: "¿Y qué vende, señor? Una frase basta.",
        crecer: "¿Y a dónde quiere llevarlos? Su perfil, su comunidad, su lista…",
        educar: "¿Y qué quiere que hagan al terminar de verlo? Escribirle, unirse a algo suyo, probar lo que enseñó…",
      }, q: "¿Y qué quiere que hagan al terminar de verlo?",
      // ⚠️ Danny: «una persona normal no respondería eso». Cierto — responde corto y
      // vago. El ejemplo enseña la FORMA que sí sirve, para que hasta la respuesta
      // rápida traiga un destino.
      ph: "Ej: que me escriban por DM · que entren a mi comunidad · que reserven una cita" },
  ];
  // El texto de la pregunta puede depender de lo ya respondido (hoy solo `destino`).
  function textoPregunta(q, p) {
    if (!q) return "";
    if (q.qDe) return q.qDe(p);                                                    // el subtipo: el texto sale de la puerta elegida
    if (q.qPorObjetivo) { const o = p && p.objetivo; return q.qPorObjetivo[o] || q.q; }
    return q.q;
  }
  const SKILLS_SUG = [
    { id: "reel_script", label: "🎬 Un guion para Reel" },
    { id: "image_post", label: "🖼️ Un carrusel con imagen" },
    { id: "social_piece", label: "📱 El texto de un post" },
    { id: "seo_meta", label: "🏆 Títulos + etiquetas" },
    { id: "content_ideas", label: "💡 Ideas para la semana" },
    { id: "repurpose", label: "🔁 Reutilizar un texto" },
  ];
  const SKILL_SHORT = { reel_script: "guion", image_post: "carrusel", social_piece: "texto de post", seo_meta: "títulos y etiquetas", content_ideas: "plan de ideas", repurpose: "adaptación" };
  // "títulos y etiquetas" es PLURAL: con "su" fijo salía "preparará su títulos y etiquetas"
  // y "su títulos y etiquetas está en su estudio". Mismo tropiezo que "Guion lista".
  const SKILL_PLURAL = { seo_meta: true };
  function suSkill(id)   { return SKILL_PLURAL[id] ? "sus" : "su"; }
  function estaSkill(id) { return SKILL_PLURAL[id] ? "están" : "está"; }
  const DUR_OPTS = [{ val: "corto", label: "Corto · ~30s" }, { val: "medio", label: "~1 minuto" }, { val: "min90", label: "~1:30 min" }, { val: "largo", label: "~2 minutos" }, { val: "extendido", label: "~2:30 (máx)" }];   // duración del guion, elegible por pieza
  const KIND_LABEL = { guion: "🎬 Guion", carrusel_img: "🖼️ Carrusel", seo: "🏆 Títulos + etiquetas", ideas: "💡 Ideas", repurpose: "🔁 Repurpose", social_piece: "📱 Texto del post" };
  // Concordancia de género/número para "…{X} {listo/lista/listos/listas} en su estudio".
  const KIND_READY = { guion: "listo", carrusel_img: "listo", seo: "listos", ideas: "listas", repurpose: "listo", social_piece: "listo" };
  const KIND_META = { guion: { icon: "🎬", label: "Guiones" }, carrusel_img: { icon: "🖼️", label: "Carruseles" }, social_piece: { icon: "📱", label: "Textos" }, seo: { icon: "🏆", label: "Títulos" }, ideas: { icon: "💡", label: "Ideas" }, repurpose: { icon: "🔁", label: "Repurpose" } };
  // "¿Dónde va esto en mis redes?" — en palabras del usuario (SIN jerga; audiencia principiante).
  const KIND_WHERE = {
    guion: "El texto para grabar tu video · Reel, TikTok o Short",
    carrusel_img: "Post de varias imágenes deslizables · Instagram / Facebook",
    social_piece: "Para publicar UNA sola foto · el carrusel ya trae su propio pie",
    seo: "Títulos + hashtags para que te ENCUENTREN · no se publica solo",
    ideas: "Ideas de temas para tu semana",
    repurpose: "La misma idea adaptada a varias redes",
  };

  // ⭐ EL RÓTULO DE LA FICHA ERA SU FRASE COPIADA TAL CUAL (30-jul). Danny escribió a propósito
  //    «los errores al resibir tutorta en ccasa» y el CONTENIDO salió bien escrito —la regla de
  //    ortografía de mc-skills SÍ funciona— pero el rótulo conservaba las tres faltas: se guarda
  //    con `title: idea` (ver generarPiezaPack) y no pasa por la IA. NO se publica —a Instagram
  //    va content.caption—, pero es lo que el cliente ve en SU estudio, y leer sus propias faltas
  //    repetidas en cada ficha parece un producto roto. Cada pieza YA trae su título; se usa ese
  //    y la frase cruda queda de respaldo (perfiles viejos, formatos sin título propio).
  // ⚠️ Los nombres salen del JSON que pide cada skill; si se cambia una plantilla en mc-skills.js
  //    hay que revisar su línea aquí, o la ficha volverá callada al título crudo.
  function tituloDe(d) {
    const c = (d && d.content) || {};
    let t = "";
    if (d.kind === "carrusel_img") t = (c.slides && c.slides[0] && c.slides[0].title) || c.hook || "";   // la portada ES el título
    else if (d.kind === "guion") t = c.title || "";
    else if (d.kind === "social_piece") t = c.hook || "";
    else if (d.kind === "seo") t = (c.titulos && c.titulos[0]) || "";
    // ideas y repurpose no tienen UN título: son varias piezas dentro → se queda el suyo.
    return String(t || "").trim() || d.title || "Su pieza";
  }

  // --- EL PACK (kit cohesivo: 1 idea → varias piezas del MISMO mensaje) ---
  const COMBO_PACK = ["reel_script", "image_post", "social_piece", "seo_meta"];   // los 4 grandes
  const MAX_PACK_PIEZAS = 5;
  const PACK_GAP_MS = 14000;   // espaciado entre piezas (el server pisa 8s; Cerebras 5/min → 14s da margen)
  const SKILL_KIND = { reel_script: "guion", image_post: "carrusel_img", social_piece: "social_piece", seo_meta: "seo", content_ideas: "ideas", repurpose: "repurpose" };
  const AGENT_BY_SKILL = { image_post: "Karen", reel_script: "Atlas", social_piece: "Atlas", seo_meta: "Atlas" };   // Karen = la diseñadora (visual); Atlas = el redactor (texto)
  const COMBO_CHIP = { reel_script: "🎬 Guion", image_post: "🖼️ Carrusel", social_piece: "📱 Texto del post", seo_meta: "🏆 Títulos" };   // etiquetas cortas para ajustar el combo
  const PIEZA_EN_FRASE = { reel_script: "el guion del video", image_post: "el carrusel", social_piece: "el texto del post", seo_meta: "los títulos + etiquetas" };

  // ⭐⭐ CADA PIEZA NECESITA UNA CASILLA DONDE VIVIR (30-jul, idea de Danny).
  //    Antes el pack daba SIEMPRE las mismas 4 piezas, contestara lo que contestara en
  //    «¿en qué redes publica?». Se descubrió rastreando por qué las etiquetas de seo_meta
  //    NUNCA aparecían en Instagram: no es un fallo del prompt — es que Instagram tiene UNA
  //    casilla (el pie) y seo_meta escribe para las TRES de YouTube (título/descripción/tags).
  //    Esa pieza no tenía dónde publicarse: se generaba, se cobraba y moría en el estudio.
  //    Danny: «que salga según cada plataforma lo permita, así no sale todo al azar».
  //    La respuesta YA se guardaba (MC_PERFIL_FIELDS) y solo servía para el ESTILO del
  //    brief («- Publica en: …»); ahora ese mismo dato decide QUÉ se genera.
  // ⚠️ NO BLOQUEA, misma doctrina que los nichos («bloquear perdería la venta»): esto manda
  //    en el pack POR DEFECTO, no en lo permitido. El servidor sigue aceptando las 6 skills,
  //    así que pedirla a mano en el chat funciona igual. Por eso no se toca server.js.
  // ⚠️ Las plataformas se guardan TAL CUAL el chip ("Instagram", con mayúscula: ver
  //    _mcCleanPerfilField, que solo recorta). Comparar sin bajar a minúsculas no casaría
  //    nada, caería al salvavidas y el filtro PARECERÍA funcionar sin hacer nada — el fallo
  //    mudo de siempre. De ahí el .toLowerCase().
  const PIEZA_CASILLA = {
    reel_script:  ["tiktok", "instagram", "youtube", "linkedin", "facebook"],   // todas admiten video
    image_post:   ["instagram", "facebook", "linkedin", "tiktok"],              // fotos deslizables — YouTube no tiene
    social_piece: ["instagram", "facebook", "linkedin", "tiktok"],              // el pie de una foto — en YouTube lo cubre seo_meta
    seo_meta:     ["youtube"],                                                  // título + descripción + tags en casillas SEPARADAS
  };
  // Las piezas del pack que tienen dónde publicarse en SUS redes. Dos salvavidas, para que
  // nadie acabe peor que hoy: sin respuesta → las 4; si el filtro dejara el kit vacío → las 4.
  // ⚠️ La primera versión solo filtraba EL PACK y el menú suelto seguía ofreciendo la pieza:
  //    la misma pantalla la escondía del kit y a la vez la ofrecía dos filas más abajo. Lo vio
  //    Danny en su propia captura. La pregunta «¿tiene dónde publicarse?» es UNA, así que vive
  //    aquí y la usan los dos sitios — separarlas es como vuelven a desincronizarse.
  // ⚠️⚠️ EL SALVAVIDAS VA DENTRO, y esto costó un fallo: la primera versión lo tenía FUERA,
  //    en packPara («si el kit queda vacío → las 4»). El menú llamaba a cabeEn directo y se
  //    quedaba sin él: con una red que no está en la tabla (un "Pinterest") el kit ofrecía 4
  //    piezas y el menú NINGUNA. Dos pantallas discrepando otra vez — el bug que veníamos a
  //    matar. Un salvavidas que hay que acordarse de poner en cada sitio se olvida en uno.
  function cabeEn(sk, p) {
    const casillas = PIEZA_CASILLA[sk];
    if (!casillas) return true;                     // ideas / reutilizar: no se publican solas, siempre a la vista
    const redes = (p && Array.isArray(p.plataformas) ? p.plataformas : []).map(function (x) { return String(x == null ? "" : x).toLowerCase().trim(); }).filter(Boolean);
    if (!redes.length) return true;                 // sin respuesta → no escondemos nada
    const alguna = Object.keys(PIEZA_CASILLA).some(function (k) {
      return PIEZA_CASILLA[k].some(function (r) { return redes.indexOf(r) >= 0; });
    });
    if (!alguna) return true;                       // no reconocemos NINGUNA de sus redes → no opinamos
    return casillas.some(function (r) { return redes.indexOf(r) >= 0; });
  }
  function packPara(p) {
    const caben = COMBO_PACK.filter(function (sk) { return cabeEn(sk, p); });
    return caben.length ? caben : COMBO_PACK;
  }
  // "a, b y c" — para prometer EXACTAMENTE las piezas que van a caer, ni una más.
  function listaNatural(arr) {
    if (arr.length <= 1) return arr[0] || "";
    return arr.slice(0, -1).join(", ") + " y " + arr[arr.length - 1];
  }

  function firstUnansweredIdx(p) {
    for (let i = 0; i < PREGUNTAS.length; i++) {
      const q = PREGUNTAS[i];
      // Una pregunta con `soloSi` se SALTA si su condición no se cumple — así el
      // subtipo solo se pregunta a las 4 puertas que agrupan oficios, y las otras 6
      // pasan de largo sin una pregunta de más.
      if (q.soloSi && !q.soloSi(p)) continue;
      const k = q.key, v = p ? p[k] : null;
      const missing = q.multi ? !(Array.isArray(v) && v.length) : !(v && String(v).trim());
      if (missing) return i;
    }
    return -1;
  }
  // Los chips pueden ser fijos o depender de lo ya respondido (el subtipo).
  function chipsDe(q, p) { return q ? (q.chipsDe ? q.chipsDe(p) : q.chips) : null; }
  // 🗣️ EL ECO — que se sienta una conversación, no un formulario.
  // Danny: «recuerda actuar como un chat verdadero, que se sienta que fuese mi ayuda
  // idónea». Tenía razón: hoy pregunta, uno responde, y pasa a la siguiente SIN dar
  // señal de haber escuchado. Un ayudante de verdad le devuelve lo que oyó.
  // Se hace con SUS PROPIAS PALABRAS: cero llamadas de IA, cero riesgo de inventar.
  // Sirve para cualquier nicho porque no interpreta nada — solo repite y confirma.
  function ecoDe(key, display, valor) {
    const t = String(display == null ? "" : display).trim().replace(/\s+/g, " ");
    if (!t) return "";
    const corto = t.length > 90 ? t.slice(0, 88).replace(/[\s,;.]+$/, "") + "…" : t;
    const enMinuscula = corto.charAt(0).toLowerCase() + corto.slice(1);
    switch (key) {
      case "nicho":       return "« " + corto + " ». Anotado, señor.";
      // El eco del subtipo confirma que se quedó con lo ESPECÍFICO: es justo la duda
      // que tuvo Danny al probarlo («no me dijo cuál es en específico»).
      case "sub_nicho":   return "Perfecto — entonces lo suyo es " + enMinuscula + ". Ahí me centro.";
      case "audiencia":   return "Entendido: escribo para " + enMinuscula + ".";
      case "voz":         return "Con voz " + enMinuscula + ", entonces.";
      case "plataformas": return "Perfecto — le escribo pensando en " + corto + ".";
      case "duracion_reel": return "Anotado: " + enMinuscula + ".";
      case "objetivo":    return "Bien. Todo lo que le prepare irá a eso.";
      case "destino":     return "Ahí es donde llevaré cada pieza, señor.";
      default:            return "Anotado, señor.";
    }
  }
  // El resumen del final: le demuestra que entendió, en vez de un seco "ya conozco su marca".
  function resumenMarca(p) {
    if (!p) return "";
    const trozo = (v) => String(v == null ? "" : v).trim().replace(/\s+/g, " ");
    const partes = [];
    if (trozo(p.nicho)) partes.push(trozo(p.nicho));
    if (trozo(p.audiencia)) partes.push("para " + trozo(p.audiencia));
    if (Array.isArray(p.plataformas) && p.plataformas.length) partes.push("en " + p.plataformas.join(" y "));
    if (trozo(p.voz)) partes.push("con voz " + trozo(p.voz).toLowerCase());
    if (!partes.length) return "";
    let s = partes.join(", ");
    if (s.length > 240) s = s.slice(0, 238).replace(/[\s,;.]+$/, "") + "…";
    return s;
  }
  function delay(ms) { return new Promise((r) => setTimeout(r, ms)); }
  let _uid = 0; function uid() { return "m" + (++_uid); }

  function copiar(text) {
    try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text); return; } } catch (e) {}
    try { const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } catch (e) {}
  }
  function carouselTpl() { try { return (localStorage.getItem("jarvis_carousel_template") || "perfil1").trim() || "perfil1"; } catch (e) { return "perfil1"; } }
  // ⚠️ SALTA LA LÍNEA QUE YA ES EL TÍTULO. Antes cogía la primera a secas, y daba igual porque
  //    el rótulo era la frase cruda del usuario. Al pasar el rótulo al título PROPIO de la pieza
  //    (ver tituloDe), esa primera línea resultó ser justo la misma: la tarjeta enseñaba el mismo
  //    texto dos veces seguidas. Lo vio Danny en la misma captura que confirmaba el arreglo.
  //    Es el principio del pie del carrusel aplicado aquí: si ya lo leyó arriba, abajo va otra cosa.
  // ⚠️ Se comparan SIN tildes ni signos: «¿Qué puede arruinar tu torta?» y «Que puede arruinar tu
  //    torta» son la misma frase para el lector, y compararlas crudas dejaría pasar el duplicado.
  function archPreview(d, salvo) {
    const t = (d && d.content_text) || "";
    const norm = function (x) { return String(x || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]/g, ""); };
    const s = norm(salvo);
    const lineas = t.split("\n").map((x) => x.trim()).filter(Boolean);
    const line = lineas.filter(function (x) { return !s || norm(x) !== s; })[0] || "";
    return line.slice(0, 150);
  }
  function str(x) { return x == null ? "" : String(x).trim(); }
  // Persistencia LIGERA de la conversación (Fase 3): últimos ~40 bubbles en localStorage, POR NICHO.
  function chatKey(nicho) { return nicho ? "jarvis_mcx_chat_v1#" + nicho : "jarvis_mcx_chat_v1"; }

  // ⭐ CUÁNTO LE CONOCE JARVIS — idea de Danny (28-jul), y no es un adorno: está MEDIDO que
  //    llenar la ficha mejora las piezas (8 datos → 42 % sin ningún fallo; 17 datos → 58 %;
  //    y doblar la ficha bajó «sin nada comprobable» del 50 % al 14,6 %). La barra es su nota.
  //    Se cuentan FRENTES cubiertos, no datos sueltos: se podrían tener veinte hechos y todos
  //    de precio. Vive aquí arriba porque hace falta en TRES sitios — el panel, el momento en
  //    que se decide si sigue con la 2ª ronda, y la cabecera de cada pregunta.
  const MCX_FRENTES = [
    { k: "sus números", re: /precio|cuesta|soles|s\/|tarifa|cobra|dura|minutos|horas|dias|semanas/ },
    { k: "su catálogo", re: /ofrec|vende|tipos de|sabores|servicios|productos|menu|carta/ },
    { k: "sus condiciones", re: /anticipacion|no acepta|entrega|delivery|atiende|horario|adelanto|cupo|al dia/ },
    { k: "su método", re: /como (?:lo )?hace|proceso|prepara|trabaja|usa |tecnica|receta|paso|horne/ },
    { k: "lo que NUNCA debe decirse", re: /nunca debe decirse|no debe decir|por ley|no es cierto/ },
    { k: "su historia", re: /empec|empezo|comenz|su historia|desde 20\d\d|lleva \d+ a[nñ]os/ },
    { k: "sus señales", re: /se (?:le )?nota|senal|se da cuenta|\bsi (?:el|la|los|las|ves|notas|toca|huele|sale|queda)\b|cuando (?:el|la|ya|se)\b|se ve\b|huele a|al tacto|suena a/ },
    { k: "lo que ya va incluido", re: /ya (?:va|van|esta|estan) incluid|incluye|se paga aparte|se cobra aparte|sin costo extra/ },
  ];
  function cuantoSabe(items) {
    const txt = (Array.isArray(items) ? items : []).map((h) => String((h && h.t) || h || ""))
      .join(" · ").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
    const hechas = MCX_FRENTES.filter((f) => f.re.test(txt));
    const faltan = MCX_FRENTES.filter((f) => !f.re.test(txt));
    return { pct: Math.round(hechas.length / MCX_FRENTES.length * 100), hechas, faltan, total: MCX_FRENTES.length };
  }
  // "Andamiaje" interactivo del chat (selecciones de chip + prompts con botones) = UI transitoria, NO
  // conversación → NO se persiste. Sus botones no sobreviven al recargar → dejarían texto huérfano y se
  // acumulan sesión tras sesión. Detección por TEXTO (las banderas se pierden al guardar).
  // 🐛 ANTES ESTO SE ADIVINABA POR EL TEXTO — y chocó. Danny lo cazó en vivo: contestó
  // «~1 minuto» a la pregunta «¿cuánto duran sus videos?» de la entrevista de marca, y
  // al recargar esa respuesta había desaparecido. Motivo: los BOTONES de duración de un
  // guion usan ESA MISMA etiqueta, así que el filtro la tomó por ruido de interfaz y la
  // descartó. (Y era incoherente: «Menos de 30s» sí sobrevivía, «~2 minutos» no.)
  // Ahora la interfaz MARCA lo suyo en origen con `ui:true` y no hay nada que adivinar.
  // El texto queda solo de RESPALDO, para conversaciones guardadas antes de este cambio.
  function isChatScaffold(m) {
    if (m && m.sys) return true;   // avisos de ESTADO (Instagram conectado/desconectado…): se ven, no se guardan
    if (m && m.ui) return true;    // etiqueta de botón: la puso la interfaz, no la escribió él
    if (m.from === "j") return /¿le preparo algo más|¿qué le preparo|¿de cuánto lo quiere|aquí seguimos/i.test(m.text);
    // ⚠️ Las duraciones salieron de esta lista A PROPÓSITO: son respuestas legítimas de
    // la entrevista. Los botones de duración ya vienen marcados con `ui`.
    return /^(un pack completo|un guion para reel|un carrusel con imagen|el texto de un post|títulos \+ etiquetas|ideas para la semana|reutilizar un texto)/i.test(m.text);
  }
  function loadChatLS(nicho) {
    try {
      const raw = localStorage.getItem(chatKey(nicho)); if (!raw) return null;
      const a = JSON.parse(raw); if (!Array.isArray(a)) return null;
      // `bienvenida` viaja a propósito: si se pierde al recargar, renombrar la marca ya
      // no podría poner al día el saludo (se quedaría con el nombre viejo otra vez).
      return a.filter(function (m) { return m && m.text && (m.from === "u" || m.from === "j") && !isChatScaffold(m); })
              .slice(-40).map(function (m) { return { id: uid(), from: m.from, text: String(m.text), bienvenida: !!m.bienvenida, preg: m.preg || null }; });
    } catch (e) { return null; }
  }
  function saveChatLS(msgs, nicho) {
    try {
      const a = (msgs || []).filter(function (m) { return m && m.text && (m.from === "u" || m.from === "j") && !isChatScaffold(m); })
                 .slice(-40).map(function (m) {
                   const o = { from: m.from, text: m.text };
                   if (m.bienvenida) o.bienvenida = 1;
                   if (m.preg) o.preg = m.preg;   // qué pregunta es → sobrevive a que cambiemos su redacción
                   return o;
                 });
      localStorage.setItem(chatKey(nicho), JSON.stringify(a));
    } catch (e) {}
  }
  // ⭐ CONTEXTO FRESCO (Paso 1 · "un solo cerebro"): resumen corto de lo que el
  // USUARIO ha venido diciendo, para que la pieza no salga ciega a la charla.
  // SOLO sus palabras — las respuestas de JARVIS son eco ("con gusto, señor") y
  // se comerían la mitad del presupuesto sin aportar nada nuevo.
  // 600 chars = lo que acepta el server. Se llena de lo MÁS NUEVO hacia atrás
  // (lo reciente nunca se pierde) y se entrega en orden cronológico (se lee natural).
  const CTX_CHAT_MAX = 600;
  // Chips que se empujan al chat como si fueran del usuario, pero son BOTONES,
  // no algo que él haya contado. isChatScaffold no los cubre (llevan emoji delante).
  // ⚠️ Coinciden EXACTO, no por prefijo. Con prefijo, un "Mejor no uso ese enfoque,
  // prefiero el otro" escrito DE VERDAD se borraba en silencio — perder contexto real
  // es peor que colar un botón. Y solo van los textos que algún pushMsg({from:"u"})
  // manda literalmente (verificado uno por uno); nada inventado "por si acaso".
  const CTX_CHIPS = ["no sé, deme ideas", "un pack completo", "sí, termínelo", "ya no"];
  // Espejo del guardián del server (_MC_SECRET_RE + _MC_SECRET_BLOB_RE): si el usuario
  // pega una llave en el chat, NO viaja al prompt del proveedor de IA.
  // El 2º patrón cubre las llaves SIN prefijo (Mistral, Cloudflare): palabra suelta de
  // 20+ con mayúsculas + minúsculas + dígitos. Lo humano no se ve así.
  const CTX_SECRET_RE = /(gsk_|csk_|tvly-|sk-[A-Za-z0-9]{6}|AIza[0-9A-Za-z_\-]{10}|xai-|[A-Fa-f0-9]{32,})/;
  // Espejo de _mcLooksSecret: ignora los dígitos FINALES para no comerse un nombre de
  // marca con año ("DannyElArquitecto2026"); una llave lleva los dígitos intercalados.
  function ctxPareceLlave(s) {
    if (CTX_SECRET_RE.test(s)) return true;
    var toks = s.match(/[A-Za-z0-9_-]{20,}/g) || [];
    for (var i = 0; i < toks.length; i++) {
      var core = toks[i].replace(/\d+$/, "");
      if (core.length < 20) continue;
      if (!/[a-z]/.test(core) || !/[A-Z]/.test(core) || !/\d/.test(core)) continue;
      return true;
    }
    return false;
  }
  function buildContextoChat(msgs, excluir) {
    try {
      const skip = String(excluir == null ? "" : excluir).trim().toLowerCase();
      const utiles = [];
      for (const m of (msgs || [])) {
        if (!m || m.from !== "u" || !m.text) continue;
        if (isChatScaffold(m)) continue;                                  // etiquetas de botón
        // quita emojis/símbolos iniciales SIN \p{...} (compatibilidad con el Babel del CDN)
        const t = String(m.text).replace(/^[^A-Za-zÁÉÍÓÚÜÑáéíóúüñ0-9¿¡"']+/, "").trim();
        if (t.length < 8) continue;                                       // chips y ruido corto
        if (isChatScaffold({ from: "u", text: t })) continue;             // el mismo chip, ya sin emoji
        if (CTX_CHIPS.indexOf(t.toLowerCase()) >= 0) continue;            // botón, no algo que él contó
        if (ctxPareceLlave(t)) continue;                                  // parece una llave → jamás al prompt
        if (skip && t.toLowerCase() === skip) continue;                   // la idea actual YA viaja como ideaBase
        utiles.push(t);
      }
      const elegidos = [];
      let total = 0;
      for (let i = utiles.length - 1; i >= 0; i--) {
        const t = utiles[i].slice(0, 240);
        if (total + t.length + 1 > CTX_CHAT_MAX) break;
        elegidos.unshift(t);
        total += t.length + 1;
      }
      return elegidos.join("\n").slice(0, CTX_CHAT_MAX);
    } catch (e) { return ""; }
  }
  // El ángulo (JSON de pack_angle) → texto legible que se re-inyecta a cada pieza como `context`.
  function angleToText(a) {
    if (!a) return "";
    const parts = [];
    if (str(a.mensaje_central)) parts.push("Mensaje central: " + str(a.mensaje_central));
    if (str(a.gancho)) parts.push("Gancho maestro: " + str(a.gancho));
    const pk = Array.isArray(a.puntos_clave) ? a.puntos_clave.map(str).filter(Boolean) : [];
    if (pk.length) parts.push("Puntos que TODA pieza debe tocar: " + pk.join(" · "));
    if (str(a.cta_tema)) parts.push("Tema del llamado a la acción: " + str(a.cta_tema));
    return parts.join("\n");
  }
  // Acota los campos del ángulo a sus topes → el JSON de la misión NUNCA revienta los 2000 chars.
  function compactAngle(a) {
    if (!a || typeof a !== "object") return null;
    const cut = function (x, n) { return str(x).slice(0, n); };
    const pk = (Array.isArray(a.puntos_clave) ? a.puntos_clave : []).slice(0, 3).map(function (x) { return cut(x, 90); }).filter(Boolean);
    return { mensaje_central: cut(a.mensaje_central, 160), gancho: cut(a.gancho, 120), puntos_clave: pk, cta_tema: cut(a.cta_tema, 60) };
  }
  // Una misión es un PACK si su objective es el JSON {v:'pack1', ...}.
  function parsePack(m) {
    if (!m || !m.objective) return null;
    try { const o = JSON.parse(m.objective); if (o && o.v === "pack1" && Array.isArray(o.plan)) return o; } catch (e) {}
    return null;
  }
  // Primer pack con status 'active' (candidato a retomar). La COMPLETITUD se verifica aparte,
  // contra el server (doneSkillsDeMision) — NO contra el estudio en memoria (que viene truncado a 100
  // y sin las ocultas → deducir "faltan" de ahí regeneraba piezas que ya existían).
  function findActivePackMission(missions) {
    if (!Array.isArray(missions)) return null;
    for (let i = 0; i < missions.length; i++) {   // missions/list viene ordenado por updated_at DESC
      const m = missions[i];
      if (m && m.status === "active" && parsePack(m)) return m;
    }
    return null;
  }
  // Fuente AUTORITATIVA de qué skills de una misión YA existen (TODAS las status; consulta directa al server).
  async function doneSkillsDeMision(mid) {
    const done = {};
    try {
      const r = await window.MCNet.listDrafts({ mission_id: mid, limit: 50 });
      if (r && r.ok && Array.isArray(r.deliverables)) {
        r.deliverables.forEach(function (d) { if (d && d.gen_meta && d.gen_meta.skill) done[d.gen_meta.skill] = 1; });
        return { ok: true, done: done };
      }
    } catch (e) {}
    return { ok: false, done: done };
  }

  // ---------- estilos (una vez) ----------
  function injectCss() {
    if (document.getElementById("mcx-style")) return;
    const card = "border:1px solid rgba(126,232,255,.22);border-radius:16px;background:linear-gradient(180deg,rgba(8,20,30,.92),rgba(5,13,21,.96));box-shadow:0 12px 34px rgba(0,0,0,.35);overflow:hidden;";
    const css = [
      ".mcx-studio{display:flex;flex:1 1 auto;min-height:0;min-width:0;gap:14px;padding:14px;font-family:Inter,system-ui,sans-serif;width:100%;}",
      ".mcx-left{flex:2 1 0;min-width:330px;min-height:0;display:flex;flex-direction:column;position:relative;" + card + "}",   // position:relative ancla el panel "Lo que sé de usted"
      ".mcx-right{flex:3 1 0;min-width:380px;min-height:0;display:flex;flex-direction:column;" + card + "}",
      ".mcx-head{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid rgba(126,232,255,.14);background:linear-gradient(90deg,rgba(126,232,255,.08),rgba(126,232,255,0));}",
      ".mcx-dot{width:9px;height:9px;border-radius:50%;background:#7ee8ff;box-shadow:0 0 10px #7ee8ff;flex:none;}",
      ".mcx-title{color:rgba(224,244,252,.96);font-size:14px;font-weight:700;letter-spacing:.01em;}",
      ".mcx-title small{display:block;color:rgba(150,185,205,.7);font-size:11px;font-weight:500;}",
      ".mcx-badge{margin-left:auto;font-size:10px;font-weight:800;letter-spacing:.08em;color:#7ee8ff;border:1px solid rgba(126,232,255,.35);border-radius:999px;padding:3px 9px;}",
      ".mcx-reset{font-size:10px;font-weight:700;color:rgba(180,200,215,.62);background:transparent;border:1px solid rgba(126,232,255,.22);border-radius:999px;padding:3px 10px;cursor:pointer;font-family:inherit;transition:background .14s,color .14s;}",
      // "Lo que sé de usted": el usuario VE y BORRA lo que JARVIS le anotó.
      ".mcx-saber{position:absolute;top:52px;left:12px;right:12px;z-index:6;background:rgba(6,16,24,.98);border:1px solid rgba(126,232,255,.26);border-radius:12px;padding:13px 14px;box-shadow:0 14px 40px rgba(0,0,0,.55);max-height:62%;overflow-y:auto;}",
      ".mcx-saber h4{margin:0 0 3px;font-size:12.5px;color:#cdeefb;letter-spacing:.02em;}",
      ".mcx-saber p.sub{margin:0 0 10px;font-size:10.5px;color:rgba(150,190,215,.72);line-height:1.45;}",
      ".mcx-saber ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;}",
      ".mcx-saber li{display:flex;align-items:flex-start;gap:9px;font-size:12px;color:#dceefa;line-height:1.45;background:rgba(126,232,255,.05);border-radius:8px;padding:7px 9px;}",
      ".mcx-saber li span{flex:1 1 auto;}",
      ".mcx-saber li small{display:block;font-size:9.5px;color:rgba(150,190,215,.55);margin-top:2px;}",
      ".mcx-olvidar{flex:0 0 auto;font-size:10px;font-weight:700;color:#ffcab0;background:rgba(255,120,90,.08);border:1px solid rgba(255,150,120,.32);border-radius:999px;padding:3px 9px;cursor:pointer;font-family:inherit;}",
      ".mcx-olvidar:disabled{opacity:.45;cursor:not-allowed;}",
      ".mcx-saber .vacio{font-size:11.5px;color:rgba(150,190,215,.7);line-height:1.5;}",
      ".mcx-reset:hover{background:rgba(255,120,90,.1);color:#ffcab0;border-color:rgba(255,150,120,.35);}",
      ".mcx-reset:disabled{opacity:.4;cursor:not-allowed;}",
      ".mcx-nichobar{flex:0 0 auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid rgba(126,232,255,.14);background:linear-gradient(90deg,rgba(126,232,255,.05),rgba(126,232,255,0));}",
      ".mcx-nichobar-lbl{font-size:10px;font-weight:800;letter-spacing:.07em;color:rgba(150,185,205,.6);}",
      ".mcx-nacts{display:inline-flex;gap:8px;align-items:center;flex-wrap:wrap;margin-left:6px;padding-left:12px;border-left:1px solid rgba(126,232,255,.16);}",
      ".mcx-nacts .mcx-nchip{font-size:11px;padding:4px 10px;}",
      ".mcx-nchip{padding:5px 12px;border-radius:999px;border:1px solid rgba(126,232,255,.25);background:rgba(126,232,255,.05);color:#cdeefb;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;transition:background .14s;}",
      ".mcx-nchip:hover{background:rgba(126,232,255,.14);}",
      ".mcx-nchip.on{background:linear-gradient(135deg,#7ee8ff,#3aa8ff);color:#04202b;border-color:transparent;}",
      ".mcx-nchip.add{border-style:dashed;color:#9be8c8;}",
      ".mcx-nchip:disabled{opacity:.4;cursor:not-allowed;}",
      ".mcx-nchip.mut{border-color:rgba(255,150,120,.35);color:#ffcab0;}",
      ".mcx-nchip.mut:hover{background:rgba(255,120,90,.12);}",
      ".mcx-nnew{display:inline-flex;gap:6px;align-items:center;}",
      ".mcx-ninput{padding:5px 10px;border-radius:8px;border:1px solid rgba(126,232,255,.35);background:rgba(10,20,30,.8);color:#eaf7ff;font-size:12px;font-family:inherit;}",
      ".mcx-ninput:focus{outline:none;border-color:rgba(126,232,255,.55);}",
      ".mcx-msgs{flex:1 1 auto;min-height:0;overflow-y:auto;padding:16px;display:flex;flex-direction:column;}",
      ".mcx-msgs-inner{margin-top:auto;display:flex;flex-direction:column;gap:12px;}",
      ".mcx-row{display:flex;}.mcx-row.j{justify-content:flex-start;}.mcx-row.u{justify-content:flex-end;}",
      ".mcx-bub{max-width:88%;padding:10px 13px;border-radius:14px;font-size:13.5px;line-height:1.5;white-space:pre-wrap;word-break:break-word;}",
      ".mcx-row.j .mcx-bub{background:rgba(14,26,38,.9);border:1px solid rgba(126,232,255,.18);color:rgba(226,242,250,.95);border-top-left-radius:5px;}",
      ".mcx-row.u .mcx-bub{background:linear-gradient(135deg,rgba(34,200,255,.22),rgba(34,150,255,.14));border:1px solid rgba(126,232,255,.4);color:#eaf7ff;border-top-right-radius:5px;}",
      ".mcx-think{display:flex;align-items:center;gap:9px;color:rgba(150,190,210,.8);font-size:12.5px;font-style:italic;padding:2px 4px;}",
      ".mcx-think .mcx-td{width:6px;height:6px;border-radius:50%;background:#7ee8ff;animation:mcx-pulse 1s ease-in-out infinite;}",
      ".mcx-think .mcx-td:nth-child(2){animation-delay:.18s;}.mcx-think .mcx-td:nth-child(3){animation-delay:.36s;}",
      "@keyframes mcx-pulse{0%,100%{opacity:.3;transform:scale(.8);}50%{opacity:1;transform:scale(1.1);}}",
      ".mcx-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:2px;}",
      ".mcx-chip{padding:8px 14px;border-radius:999px;border:1px solid rgba(126,232,255,.35);background:rgba(126,232,255,.06);color:#cdeefb;font-size:12.5px;font-weight:600;cursor:pointer;transition:background .14s,transform .1s;font-family:inherit;}",
      ".mcx-chip:hover{background:rgba(126,232,255,.16);}.mcx-chip:active{transform:scale(.97);}",
      ".mcx-chip:disabled{opacity:.45;cursor:not-allowed;}",
      ".mcx-chip.on{background:linear-gradient(135deg,#7ee8ff,#3aa8ff);color:#04202b;border-color:transparent;}",
      ".mcx-chip.sug{border-color:rgba(126,232,255,.5);background:rgba(126,232,255,.1);}",
      ".mcx-chip.pack{background:linear-gradient(135deg,#ffd98a,#ff9d5c);color:#2a1400;border-color:transparent;font-weight:800;box-shadow:0 4px 14px rgba(255,160,80,.25);}",
      ".mcx-chip.pack:hover{background:linear-gradient(135deg,#ffe6a8,#ffab6f);}",
      ".mcx-note{color:rgba(150,185,205,.7);font-size:11.5px;margin-top:6px;}",
      ".mcx-dock{flex:0 0 auto;border-top:1px solid rgba(126,232,255,.14);padding:12px 16px;background:rgba(4,11,18,.6);}",
      ".mcx-inrow{display:flex;gap:9px;align-items:flex-end;margin-top:9px;}",
      ".mcx-input{flex:1;resize:none;min-height:42px;max-height:120px;padding:10px 12px;border-radius:10px;border:1px solid rgba(126,232,255,.25);background:rgba(10,20,30,.8);color:#eaf7ff;font-size:13.5px;font-family:inherit;line-height:1.4;}",
      ".mcx-input:focus{outline:none;border-color:rgba(126,232,255,.55);}",
      ".mcx-send{flex:none;padding:0 18px;height:42px;border-radius:10px;border:none;cursor:pointer;background:linear-gradient(135deg,#7ee8ff,#3aa8ff);color:#04202b;font-size:13px;font-weight:800;font-family:inherit;}",
      ".mcx-send:disabled{opacity:.4;cursor:not-allowed;}",
      // Fila de confirmar de una pregunta de varias opciones: la cuenta a la izquierda,
      // el botón a la DERECHA — el mismo sitio donde vive «Enviar» en todo el chat.
      ".mcx-multi-go{display:flex;align-items:center;gap:10px;margin-top:10px;}",
      ".mcx-multi-cuenta{flex:1 1 auto;min-width:0;font-size:12px;color:rgba(150,185,205,.75);}",
      ".mcx-tag{display:inline-block;font-size:10px;font-weight:800;letter-spacing:.06em;color:#8fe;opacity:.75;margin-bottom:6px;}",
      // --- ESTUDIO (derecha) ---
      ".mcx-arch-head{flex:0 0 auto;display:flex;align-items:center;gap:9px;padding:12px 16px;border-bottom:1px solid rgba(126,232,255,.14);color:rgba(224,244,252,.96);font-size:14px;font-weight:700;background:linear-gradient(90deg,rgba(126,232,255,.06),rgba(126,232,255,0));}",
      ".mcx-arch-count{margin-left:auto;font-size:11px;font-weight:600;color:rgba(150,185,205,.7);}",
      ".mcx-arch-list{flex:1 1 auto;min-height:0;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:12px;}",
      ".mcx-arch-empty{margin:auto;text-align:center;color:rgba(150,185,205,.6);font-size:13px;line-height:1.6;padding:30px;}",
      ".mcx-arch-empty .em{font-size:34px;display:block;margin-bottom:10px;opacity:.7;}",
      ".mcx-arch-card{border:1px solid rgba(126,232,255,.2);border-radius:12px;background:rgba(6,16,24,.92);padding:12px 14px;}",
      ".mcx-arch-card.ok{border-color:rgba(58,210,154,.5);}",
      ".mcx-arch-top{display:flex;align-items:flex-start;gap:10px;}",
      ".mcx-arch-info{flex:1;min-width:0;}",
      ".mcx-arch-kind{font-size:10px;font-weight:800;letter-spacing:.05em;color:#7ee8ff;}",
      ".mcx-arch-title{color:rgba(230,244,252,.96);font-size:14px;font-weight:700;margin:3px 0;}",
      ".mcx-where{color:rgba(150,190,210,.72);font-size:11px;line-height:1.35;margin:2px 0 3px;font-style:italic;}",
      ".mcx-arch-prev{color:rgba(180,205,222,.75);font-size:12px;line-height:1.45;}",
      ".mcx-arch-okpill{flex:none;font-size:10px;font-weight:800;color:#3ad29a;border:1px solid rgba(58,210,154,.5);border-radius:999px;padding:2px 8px;}",
      ".mcx-qual{display:flex;align-items:center;flex-wrap:wrap;gap:7px;margin-top:11px;padding-top:10px;border-top:1px solid rgba(126,232,255,.12);}",
      ".mcx-qual-lbl{font-size:11px;font-weight:700;color:rgba(150,190,210,.8);}",
      ".mcx-qual-chip{padding:5px 11px;border-radius:999px;border:1px solid rgba(126,232,255,.25);background:transparent;color:#a9c9d8;font-size:11.5px;font-weight:700;cursor:pointer;font-family:inherit;transition:background .14s;}",
      ".mcx-qual-chip:hover{background:rgba(126,232,255,.1);}",
      ".mcx-qual-chip.on{background:linear-gradient(135deg,#7ee8ff,#3aa8ff);color:#04202b;border-color:transparent;}",
      ".mcx-qual-hint{font-size:10.5px;color:rgba(150,185,205,.65);margin-left:auto;}",
      ".mcx-full{margin-top:10px;color:rgba(224,240,250,.94);font-size:12.5px;line-height:1.55;white-space:pre-wrap;max-height:340px;overflow-y:auto;border-top:1px solid rgba(126,232,255,.12);padding-top:10px;}",
      ".carfx{margin:2px 0 12px;padding:11px 13px;border-radius:12px;background:rgba(6,16,24,.5);border:1px solid rgba(126,232,255,.16);}",
      ".carfx-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;}",
      ".carfx-lbl{font-size:10px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:rgba(150,200,225,.72);}",
      ".carfx-chips{display:flex;gap:6px;flex-wrap:wrap;}",
      ".carfx-chip{padding:5px 11px;border-radius:999px;border:1px solid rgba(126,232,255,.28);background:rgba(126,232,255,.05);color:#cdeefb;font-size:11.5px;font-weight:700;cursor:pointer;transition:background .14s;font-family:inherit;}",
      ".carfx-chip:hover{background:rgba(126,232,255,.14);}",
      ".carfx-chip.on{background:linear-gradient(135deg,#7ee8ff,#3aa8ff);color:#04121c;border-color:transparent;}",
      ".carfx-field{display:flex;align-items:center;gap:7px;}",
      ".carfx-color{width:36px;height:26px;border:1px solid rgba(126,232,255,.3);border-radius:7px;background:none;cursor:pointer;padding:0;}",
      ".carfx-handle{width:120px;padding:6px 10px;border-radius:8px;border:1px solid rgba(126,232,255,.28);background:rgba(3,10,16,.7);color:#eaf6ff;font-size:12px;font-family:inherit;}",
      ".carfx-handle:focus{outline:none;border-color:rgba(126,232,255,.6);}",
      ".carfx-hint{font-size:10.5px;color:rgba(170,205,228,.78);line-height:1.5;}",
      ".carfx-sello{font-size:9.5px;font-weight:800;letter-spacing:.09em;color:rgba(126,232,255,.72);margin-bottom:8px;}",
      ".mcx-acts{display:flex;flex-wrap:wrap;gap:8px;margin-top:11px;}",
      // Discreto a propósito: es un recordatorio de mayordomo, no una alarma. Ámbar suave,
      // sin icono de advertencia ni borde rojo — la pieza no está mal, solo conviene mirarla.
      ".mcx-ojo{margin-top:9px;font-size:12px;line-height:1.45;color:rgba(255,209,128,.86);border-left:2px solid rgba(255,209,128,.35);padding-left:9px;}",
      ".mcx-btn{padding:7px 12px;border-radius:8px;border:1px solid rgba(126,232,255,.3);background:rgba(126,232,255,.07);color:#cdeefb;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;transition:background .14s;}",
      ".mcx-btn:hover{background:rgba(126,232,255,.16);}.mcx-btn:disabled{opacity:.5;cursor:not-allowed;}",
      ".mcx-btn.go{background:linear-gradient(135deg,#3ad29a,#1f9d76);color:#032018;border-color:transparent;}",
      ".mcx-btn.mut{border-color:rgba(255,150,120,.35);color:#ffcab0;background:rgba(255,120,90,.06);}",
      ".mcx-arch-tools{flex:0 0 auto;padding:10px 14px;border-bottom:1px solid rgba(126,232,255,.1);display:flex;flex-direction:column;gap:9px;}",
      ".mcx-search{width:100%;box-sizing:border-box;padding:8px 11px;border-radius:9px;border:1px solid rgba(126,232,255,.22);background:rgba(10,20,30,.7);color:#eaf7ff;font-size:12.5px;font-family:inherit;}",
      ".mcx-search:focus{outline:none;border-color:rgba(126,232,255,.5);}",
      ".mcx-arch-filters{display:flex;flex-wrap:wrap;gap:6px;}",
      ".mcx-fchip{padding:5px 10px;border-radius:999px;border:1px solid rgba(126,232,255,.25);background:transparent;color:#a9c9d8;font-size:11.5px;font-weight:600;cursor:pointer;font-family:inherit;transition:background .14s;}",
      ".mcx-fchip:hover{background:rgba(126,232,255,.1);}",
      ".mcx-fchip.on{background:linear-gradient(135deg,#7ee8ff,#3aa8ff);color:#04202b;border-color:transparent;}",
      ".mcx-sec{display:flex;flex-direction:column;gap:12px;}",
      ".mcx-sec-head{display:flex;align-items:center;gap:7px;padding:4px 2px;color:#8fb6c4;font-size:11px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;}",
      ".mcx-sec-toggle{margin-left:auto;padding:4px 11px;border-radius:999px;border:1px solid rgba(126,232,255,.32);background:rgba(126,232,255,.09);color:#cdeefb;font-size:10.5px;font-weight:700;letter-spacing:.03em;cursor:pointer;font-family:inherit;text-transform:none;}",
      ".mcx-sec-toggle:hover{background:rgba(126,232,255,.18);}",
      ".mcx-pack{border:1px solid rgba(255,190,110,.32);border-radius:14px;background:linear-gradient(180deg,rgba(42,29,11,.5),rgba(20,14,6,.35));padding:11px;display:flex;flex-direction:column;gap:10px;}",
      ".mcx-pack-head{display:flex;flex-direction:column;gap:3px;padding:2px 2px 4px;}",
      ".mcx-pack-title{color:#ffdca0;font-size:12.5px;font-weight:800;letter-spacing:.01em;}",
      ".mcx-pack-sub{color:rgba(220,200,170,.72);font-size:11.5px;line-height:1.4;}",
      ".mcx-ghost{border:1px dashed rgba(126,232,255,.28);border-radius:12px;background:rgba(6,16,24,.5);padding:11px 13px;opacity:.8;}",
    ].join("");
    const st = document.createElement("style"); st.id = "mcx-style"; st.textContent = css;
    document.head.appendChild(st);
  }

  // ---------- componente ----------
  function MCChatPanel(props) {
    const nicho = (props && props.nicho) || null;   // marca activa (portafolio); null = por defecto
    const nichoNombre = (props && props.nichoNombre) || null;   // nombre de la marca activa (para el saludo de marca nueva)
    const [perfil, setPerfil] = useState(null);
    const [msgs, setMsgs] = useState([]);
    const [thinking, setThinking] = useState(null);
    const [stage, setStage] = useState("loading");   // loading|discovery|ficha|ready|idea
    // LA FICHA DEL NEGOCIO en curso: { qs: [{k,p,ph}], i } · null = no está preguntando
    const [ficha, setFicha] = useState(null);
    // "Lo que sé de usted": JARVIS anota hechos del usuario y los mete en CADA pieza.
    // Guardar cosas sobre alguien que esa persona no puede ver ni borrar es vigilancia,
    // no memoria. Este panel es lo que separa una cosa de la otra.
    const [selloGuardado, setSelloGuardado] = useState(false);   // "✓ Guardado" efímero en la barra de estilo
    const [saberOpen, setSaberOpen] = useState(false);
    const [hechos, setHechos] = useState(null);      // null = aún no cargados
    const [olvidando, setOlvidando] = useState(null);
    const [chosen, setChosen] = useState(null);
    const [input, setInput] = useState("");
    const [multi, setMulti] = useState([]);
    // --- estudio (derecha) ---
    const [archive, setArchive] = useState([]);       // piezas creadas (nuevas arriba)
    const [openId, setOpenId] = useState(null);        // pieza expandida (Ver)
    const [guionDur, setGuionDur] = useState("");      // duración elegida para ESTE guion (vacío = default del perfil)
    const [pendingGuionIdea, setPendingGuionIdea] = useState("");   // idea del guion (vía chat libre) esperando que elija duración
    // Calidad de imagen FIJA en Recomendada (klein-4b): 9/10 + ~60 img/día (6-7 carruseles). Alta (9b) se
    // quitó — ~7/día era una trampa de cuota (un curioso quemaba su día y los siguientes salían feos).
    const [refiningId, setRefiningId] = useState(null);
    const [refineText, setRefineText] = useState("");
    const [pieceBusy, setPieceBusy] = useState(null);  // id de la pieza en acción
    const [query, setQuery] = useState("");            // buscador del estudio
    const [filterType, setFilterType] = useState(null); // filtro por tipo (kind)
    const [showFirmadas, setShowFirmadas] = useState(true); // sección Firmadas colapsable
    const [missions, setMissions] = useState([]);        // misiones (para agrupar los PACKS por mission_id)
    const [skillIds, setSkillIds] = useState(null);      // skills que ve este usuario (gate canary del chip del pack)
    const [chatLibre, setChatLibre] = useState(false);   // Fase 3: chat conversacional (flag canary)
    const [carStyleOn, setCarStyleOn] = useState(false);   // barra de estilo del carrusel en el Estudio (flag canary)
    const [igPublishOn, setIgPublishOn] = useState(false);   // auto-publicar en Instagram (flag canary)
    // Canary del propio usuario. Hoy solo lo usa el botón de ENSAYO: es una herramienta de
    // prueba de Danny, no una función del producto. Ver dónde se pinta, abajo.
    const [canaryOn, setCanaryOn] = useState(false);
    const [igConn, setIgConn] = useState(null);              // estado de conexión IG: {connected, username} | null (desconocido)
    const [igBusy, setIgBusy] = useState(false);             // conectando/desconectando Instagram
    const [igDiscConfirm, setIgDiscConfirm] = useState(false); // confirmación de desconectar (no soltar por un clic accidental)
    const [igPubConfirm, setIgPubConfirm] = useState(null);    // id de la pieza en confirmación de publicar
    const [igPubBusy, setIgPubBusy] = useState(null);          // id de la pieza publicándose
    const [carTpl, setCarTplState] = useState(function () { try { var t = (localStorage.getItem("jarvis_carousel_template") || "perfil1").trim(); return t || "perfil1"; } catch (e) { return "perfil1"; } });
    const [carColor, setCarColorState] = useState(function () { try { return (localStorage.getItem("jarvis_brand_color") || "").trim(); } catch (e) { return ""; } });
    const [carHandle, setCarHandleState] = useState(function () { try { return (localStorage.getItem("jarvis_brand_handle") || "").trim(); } catch (e) { return ""; } });
    // ⭐ EL SELLO DE MARCA: cada cambio se guarda en el SERVIDOR, por marca. Antes solo
    // iba a localStorage (un solo look para todas sus marcas, y perdido al cambiar de
    // equipo). localStorage SE MANTIENE como caché rápida — y porque el panel viejo
    // (content.jsx) todavía lee esas claves. El servidor es la fuente de verdad, y es
    // lo que permitirá que una automatización (Telegram) use SU look y no uno al azar.
    // Al guardar, la pista pasa a "✓ Guardado" unos segundos: confirmación visible de que
    // NO hay que hacer nada más (era justo la duda de Danny al probarlo).
    const selloTimerRef = useRef(null);
    function guardarSello(campos) {
      try {
        const p = window.MCNet.setEstilo(campos);
        if (p && p.then) p.then(function (r) {
          if (!(r && r.ok)) return;
          setSelloGuardado(true);
          if (selloTimerRef.current) clearTimeout(selloTimerRef.current);
          selloTimerRef.current = setTimeout(function () { setSelloGuardado(false); }, 3500);
        }, function () {});
      } catch (e) {}
    }
    function setCarTpl(id) { setCarTplState(id); try { localStorage.setItem("jarvis_carousel_template", id); } catch (e) {} guardarSello({ plantilla: id }); }
    function setCarColor(hex) { setCarColorState(hex); try { localStorage.setItem("jarvis_brand_color", hex); } catch (e) {} }   // al servidor lo manda el debounce (abajo): arrastrar el color no dispara 50 guardados
    function setCarHandle(h) { var v = (h || "").trim(); setCarHandleState(h || ""); try { if (v) localStorage.setItem("jarvis_brand_handle", v); else localStorage.removeItem("jarvis_brand_handle"); } catch (e) {} }
    // Valor APLICADO (debounced) para el re-pintado: el input se ve al instante, pero la
    // recomposición del canvas (toBlob) solo corre al asentarse → sin jank al arrastrar el color.
    const [carColorApplied, setCarColorApplied] = useState(carColor);
    const [carHandleApplied, setCarHandleApplied] = useState(carHandle);
    useEffect(function () { const t = setTimeout(function () { setCarColorApplied(carColor); }, 150); return function () { clearTimeout(t); }; }, [carColor]);
    useEffect(function () { const t = setTimeout(function () { setCarHandleApplied(carHandle); }, 300); return function () { clearTimeout(t); }; }, [carHandle]);
    const [carFont, setCarFontState] = useState(function () { try { var f = (localStorage.getItem("jarvis_carousel_font") || "impacto").trim(); return f || "impacto"; } catch (e) { return "impacto"; } });
    function setCarFont(k) { setCarFontState(k); try { localStorage.setItem("jarvis_carousel_font", k); } catch (e) {} guardarSello({ fuente: k }); }   // fuente = clic discreto → sin debounce
    // Color y @ se guardan en el servidor cuando el valor SE ASIENTA (mismo debounce que
    // usa el re-pintado) → arrastrar el selector de color no dispara decenas de guardados.
    const selloReadyRef = useRef(false);   // no guardar durante la carga inicial (pisaría lo que hay)
    const chatReadyRef = useRef(false);    // ídem para la conversación: guardar antes de leerla la BORRABA
    // Se manda TAMBIÉN cuando queda vacío: así el usuario puede BORRAR su color o su @.
    // Antes, un `if (valor)` los volvía imborrables — el servidor los reponía al recargar.
    // (selloReadyRef ya evita que el vacío inicial pise lo que hay guardado.)
    useEffect(function () {
      if (!selloReadyRef.current) return;
      guardarSello({ color: carColorApplied || "" });
    }, [carColorApplied]);
    useEffect(function () {
      if (!selloReadyRef.current) return;
      guardarSello({ handle: carHandleApplied || "" });
    }, [carHandleApplied]);
    const [packRun, setPackRun] = useState(null);        // mission_id del pack EN CURSO (para las filas fantasma)
    const [packCombo, setPackCombo] = useState(COMBO_PACK);   // combo ajustable del pack (Fase B)
    const packMenu = packPara(perfil);   // las piezas que SÍ tienen dónde publicarse en sus redes (ver PIEZA_CASILLA)
    const [verBusy, setVerBusy] = useState(null);   // id de la pieza moviéndose entre versiones (flechas ◀ ▶)
    // ⭐ EN QUÉ VERSIÓN ESTÁ CADA PIEZA ABIERTA — { id: {index, total} }. Sin esto las flechas
    //    se ven encendidas SIEMPRE y solo al pulsarlas avisan de que no hay nada: Danny lo dijo
    //    probando — «debería estar desactivado el botón, si lo veo opaco no lo pulso y no pienso
    //    que está bugueado». Tiene razón: un botón que se ve activo y no hace nada parece roto.
    const [verInfo, setVerInfo] = useState({});
    const [resumeDone, setResumeDone] = useState(false);      // el ofrecimiento de retomar ya se usó (no re-clickear)
    const [approvingPack, setApprovingPack] = useState(null); // mission_id aprobándose (aprobar-todo) — estado propio, no el centinela compartido
    const scrollRef = useRef(null);
    const startedRef = useRef(false);
    // ⚠️ El server puede responder 'falta_historia' (no es un error: es JARVIS pidiendo
    // SU historia en vez de inventársela). Se guarda aquí porque el pack lo descubre
    // dentro de generarAngulo/generarPiezaPack y quien debe hablar es armarPack.
    const faltaHistRef = useRef(null);
    const packFalloRef = useRef(null);   // POR QUÉ se cortó el pack — para contarlo de verdad
    const packStartingRef = useRef(false);   // latch SÍNCRONO: arrancó un pack (antes de que setPackRun proteja) → anti doble-pack

    const pushMsg = useCallback((m) => setMsgs((prev) => prev.concat([Object.assign({ id: uid() }, m)])), []);
    const jarvis = useCallback((text, extra) => pushMsg(Object.assign({ from: "j", text }, extra || {})), [pushMsg]);

    const pace = useCallback(async () => {
      setThinking({ label: "Procesando su respuesta…" });
      await delay(900);
      setThinking({ label: "Anotando en su perfil, señor…" });
      await delay(800);
      setThinking(null);
    }, []);

    // ⭐⭐ LA GUARDIA DE LAS LLAVES QUE FALTAN — se avisa AL ENTRAR, no cuando ya falló.
    //
    // POR QUÉ (Danny, 31-jul): «es muy probable que el usuario nuevo escriba directo en el chat,
    // y como no puso su llave de Cloudflare ni de Mistral, no responderá». Un Pro que acaba de
    // pagar escribe, no pasa nada, y concluye que compró algo roto. **Habrá un vídeo guiándole,
    // pero esto es la red por si no lo ve** — el que no vio el vídeo es justo el que se pierde.
    //
    // Las dos llaves NO fallan igual, así que no se avisan igual:
    //   · el CEREBRO   sin él JARVIS no contesta NADA  → aviso rotundo
    //   · CLOUDFLARE   el texto sale, las imágenes no  → aviso aparte, más suave
    // Y siempre se le manda a ALFRED, que es donde se pegan de verdad (antes decía
    // «JARVIS → ⚙ Llave IA», que es otro sitio y le hacía salir del Centro de Mando).
    function avisaLlavesQueFaltan() {
      try {
        const hay = (k) => { try { return !!(localStorage.getItem(k) || "").trim(); } catch (e) { return false; } };
        const cerebro = hay("jarvis_mistral_key") || hay("jarvis_gemini_key")
                     || hay("jarvis_groq_key")    || hay("jarvis_cerebras_key");
        const imagenes = hay("jarvis_cf_token") && hay("jarvis_cf_account");
        if (!cerebro) {
          jarvis("Señor, antes de empezar: me falta la llave que me da voz. Sin ella no puedo "
            + "escribirle nada. Vaya a la pestaña ALFRED, aquí arriba, y péguela en «Sus llaves» "
            + "— es gratis y se hace en un minuto. Le espero.", { sys: true });
          return;   // sin cerebro, lo de las imágenes es un detalle: no se le amontonan dos avisos
        }
        if (!imagenes) {
          jarvis("Un apunte, señor: aún no tengo su llave de imágenes. Puedo escribirle los textos "
            + "igual, pero sus carruseles saldrán sin las láminas ilustradas. Se conecta en la "
            + "pestaña ALFRED → «Sus llaves», y también es gratis.", { sys: true });
        }
      } catch (e) {}
    }

    // --- Instagram (auto-publicar, canary): conectar por OAuth en popup + estado + desconectar.
    function finishIG(ok, username) {
      setIgBusy(false);
      // `sys: true` = aviso de ESTADO, no conversación. En la captura de canary se veían
      // TRES "Instagram conectado" apilados: cada conectar/desconectar dejaba su burbuja
      // para siempre (localStorage) y además gastaba un hueco de los 16 turnos que se le
      // mandan al modelo. Se muestra igual, pero ya no se guarda ni viaja en el historial.
      if (ok) { setIgConn({ connected: true, username: username || "", dias: 60 }); jarvis("✓ Instagram conectado" + (username ? " (@" + username + ")" : "") + ", señor. Ya puede publicar sus carruseles desde el Estudio.", { sys: true }); }
      else { jarvis("No se completó la conexión con Instagram, señor. Puede intentarlo de nuevo cuando guste.", { sys: true }); }
    }
    async function conectarIG() {
      if (igBusy) return;
      setIgBusy(true);
      // Abrir el popup YA (síncrono, dentro del gesto del clic) evita el bloqueador; luego le damos la URL.
      let popup = null;
      try {
        let feat = "width=600,height=720";
        try { const w = 600, h = 720; const x = Math.max(0, (window.outerWidth - w) / 2 + (window.screenX || 0)); const y = Math.max(0, (window.outerHeight - h) / 2 + (window.screenY || 0)); feat = "width=" + w + ",height=" + h + ",left=" + x + ",top=" + y; } catch (e) {}
        popup = window.open("about:blank", "jarvis_ig_connect", feat);
      } catch (e) {}
      try {
        const r = await window.MCNet.igConnectUrl();
        if (!r || !r.ok || !r.url) { setIgBusy(false); if (popup) { try { popup.close(); } catch (e) {} } jarvis(r && r.reason === "canary_only" ? "Esta función aún está en pruebas, señor." : "No pude abrir la conexión con Instagram, señor."); return; }
        if (popup) { try { popup.location = r.url; } catch (e) { try { popup.close(); } catch (e2) {} popup = window.open(r.url, "jarvis_ig_connect"); } }
        else { popup = window.open(r.url, "jarvis_ig_connect"); }   // el bloqueador cerró el primero → intento directo
        let settled = false;
        function onMsg(ev) {
          const d = ev && ev.data;
          if (!d || d.source !== "jarvis-ig") return;
          settled = true; window.removeEventListener("message", onMsg);
          finishIG(d.ig === "connected", d.username);
        }
        window.addEventListener("message", onMsg);
        // Respaldo si el postMessage no llega (popup cerrado a mano / CSP): sondear el estado real.
        const poll = setInterval(async function () {
          if (settled) { clearInterval(poll); return; }
          if (popup && popup.closed) {
            clearInterval(poll); window.removeEventListener("message", onMsg);
            try { const rs = await window.MCNet.igStatus(); finishIG(!!(rs && rs.ok && rs.connected), rs && rs.username); } catch (e) { finishIG(false); }
          }
        }, 1200);
        setTimeout(function () { clearInterval(poll); }, 180000);   // corta el sondeo a los 3 min
      } catch (e) { setIgBusy(false); if (popup) { try { popup.close(); } catch (e2) {} } jarvis("No pude abrir la conexión con Instagram, señor."); }
    }
    async function desconectarIG() {
      if (igBusy || !(igConn && igConn.connected)) return;
      setIgBusy(true);
      try { await window.MCNet.igDisconnect(); } catch (e) {}
      setIgConn({ connected: false, username: "" }); setIgBusy(false);
      jarvis("Instagram desconectado, señor. Sus piezas del estudio quedan intactas.", { sys: true });
    }
    // Publicar un carrusel FIRMADO en Instagram: export JPEG (WYSIWYG) → subir lámina a lámina → publicar.
    async function publicarIG(d, dryRun) {
      if (igPubBusy) return;
      if (!(igConn && igConn.connected)) { setIgPubConfirm(null); jarvis("Primero conecte su Instagram, señor — el chip de arriba del Estudio."); return; }
      const slides = (d.content && d.content.slides) || [];
      const count = slides.length;
      if (count < 2) { setIgPubConfirm(null); jarvis("Ese carrusel necesita al menos 2 láminas para Instagram, señor."); return; }
      setIgPubConfirm(null);
      setIgPubBusy(d.id);
      try {
        if (openId !== d.id) setOpenId(d.id);   // montar el carrusel para que las láminas se pinten
        // `sys` = se ve mientras pasa, no queda en el historial (ver isChatScaffold).
        // Y fuera el "(si aún no cargaban…)": un mayordomo no se disculpa por adelantado.
        jarvis("Preparando las " + count + " láminas de su carrusel, señor…", { sys: true });
        const blobs = await window.MCExportSlidesJPEG(d.id, count, count * 25000 + 30000);   // escala con el nº de láminas (generación en frío)
        const caption = (d.content && d.content.caption) || d.content_text || "";
        const rs = await window.MCNet.igPublishStart(d.id, "carousel", count, caption, d.revision_count || 0);
        if (!rs || !rs.ok) {
          setIgPubBusy(null);
          if (rs && rs.reason === "already-published") jarvis("Esa versión ya la había publicado, señor" + (rs.permalink ? ": " + rs.permalink : ".") + " (Si le pidió un cambio, apruebe la nueva versión para poder publicarla.)");
          else if (rs && rs.reason === "unconfirmed") jarvis(rs.detail || "Una publicación anterior quedó sin confirmar, señor. Revise su Instagram antes de reintentar.");
          else if (rs && rs.reason === "in-progress") jarvis("Esa pieza ya se está publicando, señor.");
          else if (rs && rs.reason === "not-connected") jarvis("Su Instagram no figura conectado, señor. Reconéctelo e intente de nuevo.");
          else jarvis("No pude iniciar la publicación, señor. Intente de nuevo.");
          return;
        }
        const publishId = rs.publishId;
        for (let i = 0; i < count; i++) {
          const ru = await window.MCNet.igPublishUpload(publishId, i, blobs[i]);
          if (!ru || !ru.ok) { setIgPubBusy(null); jarvis("Falló la subida de la lámina " + (i + 1) + ", señor. Puede reintentar publicar."); return; }
        }
        jarvis(dryRun ? "Modo ensayo, señor: armando el carrusel EN Instagram (sin publicar)… puede tardar hasta un minuto." : "Enviando a Instagram, señor… esto puede tardar hasta un minuto. No cierre el Estudio.", { sys: true });
        const rc = await window.MCNet.igPublishCommit(publishId, dryRun);
        setIgPubBusy(null);
        if (rc && rc.ok && dryRun) jarvis("✅ ¡Ensayo EXITOSO, señor! Todo funcionó de punta a punta: se exportaron las láminas, se subieron, e Instagram las aceptó y armó el carrusel — pero NO se publicó nada. La tubería está sólida. Cuando quiera, publique de verdad con «Sí, publicar».", { sys: true });
        else if (rc && rc.ok) jarvis("✅ ¡Publicado en Instagram, señor!" + (rc.permalink ? " Véalo aquí: " + rc.permalink : " Ya está en su perfil."));
        else if (rc && rc.reason === "unconfirmed") jarvis(rc.detail || "No pude confirmar la publicación, señor. Revise su Instagram antes de reintentar.");
        else jarvis((dryRun ? "El ensayo falló, señor" : "No se pudo completar la publicación, señor") + (rc && rc.detail ? " (" + rc.detail + ")" : "") + ". Puede reintentar.");
      } catch (e) {
        setIgPubBusy(null);
        if (e && e.message === "slide-failed") jarvis("Una lámina de su carrusel no se pudo generar, señor. Abra el carrusel, dele «Reintentar imagen» a la que muestra el error, y vuelva a publicar.");
        else if (e && e.message === "timeout") jarvis("Las imágenes tardaron demasiado en generarse, señor. Abra el carrusel, espere a que carguen todas las láminas y reintente publicar.");
        else jarvis("No pude publicar el carrusel, señor. Puede reintentar.");
      }
    }
    function igChip() {
      const c = !!(igConn && igConn.connected);
      // El acceso de Instagram vive 60 días y se renueva SOLO al abrir esto o al publicar.
      // Solo se avisa cuando de verdad hay que hacer algo: por debajo de 14 días el chip se
      // pone ámbar y dice los días. Enseñar "quedan 58" todos los días sería ruido, y el
      // aviso que sale siempre deja de leerse justo el día que importa.
      const dias = (c && igConn.dias != null) ? igConn.dias : null;
      const urge = dias != null && dias <= 14;
      const label = igBusy ? "Instagram…"
        : (c ? ("📸 @" + (igConn.username || "instagram") + (urge ? " · " + Math.max(0, dias) + "d" : " ✓"))
             : "📸 Conectar Instagram");
      const borde = urge ? "rgba(255,190,80,.6)" : (c ? "rgba(0,224,164,.5)" : "rgba(180,120,255,.5)");
      const fondo = urge ? "rgba(255,190,80,.13)" : (c ? "rgba(0,224,164,.12)" : "rgba(180,120,255,.1)");
      const tinta = urge ? "#ffd79a" : (c ? "#7fffd8" : "#e6d2ff");
      const st = { fontSize: "11.5px", padding: "4px 11px", borderRadius: "999px", cursor: igBusy ? "default" : "pointer", border: "1px solid " + borde, background: fondo, color: tinta, whiteSpace: "nowrap", fontWeight: 600 };
      const pista = urge
        ? ("Su acceso a Instagram caduca en " + Math.max(0, dias) + " días. Se renueva solo con abrir el Centro de Mando o publicar algo.")
        : (c ? ("Instagram conectado" + (dias != null ? " · acceso válido " + dias + " días más, se renueva solo" : "") + " — clic para ver opciones")
             : "Conecta tu Instagram para publicar tus carruseles");
      return <button style={st} disabled={igBusy} title={pista} onClick={c ? function () { setIgDiscConfirm(function (v) { return !v; }); } : conectarIG}>{label}</button>;
    }

    useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs, thinking]);
    // 💾 GUARDA LA CONVERSACIÓN DE ESTA MARCA — pero NO antes de haberla recuperado.
    // 🐛 Aquí estaba la fuga real, y era destructiva: al recargar, el chat se rearmaba
    // con UNA sola pregunta y este efecto guardaba ESE estado ENCIMA de la conversación
    // entera. Cada recarga le borraba el historial, aunque estuviera bien guardado.
    // El guardado del vacío ya estaba cubierto (`msgs.length`), pero no el del "casi
    // vacío" del arranque. Mismo remedio que el sello de marca (selloReadyRef): no se
    // guarda nada hasta que el arranque terminó de leer lo que había.
    useEffect(() => { if (chatReadyRef.current && msgs.length) saveChatLS(msgs, nicho); }, [msgs]);
    useEffect(() => { if (props && props.onBusy) props.onBusy(!!(thinking || packRun)); }, [thinking, packRun]);   // avisa al padre → bloquea cambiar de marca con trabajo en curso
    // ⭐ LA LLAVE DE IMÁGENES MURIÓ Y NADIE SE LO DECÍA. Cloudflare rechaza la llave (401/403),
    //   mc-net.js sigue al generador de la casa para que el carrusel no se caiga, y avisa por
    //   aquí. Una sola vez por sesión: repetirlo en cada lámina sería peor que callarlo.
    useEffect(() => {
        let dicho = false;
        const alAvisar = () => {
            if (dicho) return; dicho = true;
            jarvis("Señor, un aviso importante: su llave de imágenes ha dejado de funcionar — el "
                + "proveedor la está rechazando. Sus láminas siguen saliendo, pero con la calidad "
                + "de respaldo, más básica. Vuelva a generarla y péguela en ALFRED → «Sus llaves» "
                + "y recuperará la calidad alta.", { sys: true });
        };
        window.addEventListener('jarvis-cf-llave-mala', alAvisar);
        return () => window.removeEventListener('jarvis-cf-llave-mala', alAvisar);
    }, []);
    // Y el techo de las láminas de respaldo: también una vez, y con la salida delante.
    useEffect(() => {
        let dicho = false;
        const alAvisar = () => {
            if (dicho) return; dicho = true;
            jarvis("Señor, se han acabado por hoy las láminas de respaldo que pongo yo de mi parte. "
                + "Los textos siguen saliendo con normalidad. Si conecta su llave de imágenes en "
                + "ALFRED → «Sus llaves» —es gratis— las tendrá en alta calidad y sin este tope.", { sys: true });
        };
        window.addEventListener('jarvis-img-casa-agotada', alAvisar);
        return () => window.removeEventListener('jarvis-img-casa-agotada', alAvisar);
    }, []);

    // arranque: cargar perfil + estudio → saludar / retomar
    useEffect(() => {
      if (startedRef.current) return; startedRef.current = true;
      (async () => {
        let p = {};
        let loadedArchive = [], loadedMissions = [];
        // ⚡ LOS CUATRO A LA VEZ (30-jul). Antes iban EN FILA —perfil, luego borradores, luego
        //    misiones, luego el sello— y ninguno necesita el resultado del anterior, así que
        //    se sumaban los cuatro viajes. Danny lo notó: la pantalla tardaba 5-6 segundos en
        //    aparecer cuando el servidor responde en 30 ms (medido). No era el servidor: era
        //    la espera encadenada. Y empeora con el uso, porque dos de esas llamadas traen
        //    hasta 100 piezas CON su contenido — cuanto más lleno el estudio, más pesa.
        //    Ahora tarda lo que el MÁS LENTO, no lo que los cuatro juntos.
        const [rPerfil, rp, ra, rm] = await Promise.all([
          window.MCNet.getPerfil().catch(function () { return null; }),
          // PENDIENTES + APROBADAS → las firmadas siguen archivadas al recargar
          // (antes solo traía 'pending' → todo lo aprobado desaparecía). Merge sin duplicados.
          window.MCNet.listDrafts({ status: "pending", limit: 100 }).catch(function () { return null; }),
          window.MCNet.listDrafts({ status: "approved", limit: 100 }).catch(function () { return null; }),
          window.MCNet.listMissions().catch(function () { return null; }),
        ]);
        if (rPerfil && rPerfil.ok && rPerfil.perfil) p = rPerfil.perfil;
        setPerfil(p);
        try {
          const seen = {}, merged = [];
          [rp, ra].forEach(function (r) {
            if (r && r.ok && Array.isArray(r.deliverables)) {
              r.deliverables.forEach(function (d) { if (d && d.id && !seen[d.id]) { seen[d.id] = 1; merged.push(d); } });
            }
          });
          merged.sort(function (a, b) { return String(b.updated_at || b.created_at || "").localeCompare(String(a.updated_at || a.created_at || "")); });
          loadedArchive = merged;
          setArchive(merged);
        } catch (e) {}
        // rm ya vino en el Promise.all de arriba — aquí solo se usa.
        if (rm && rm.ok && Array.isArray(rm.missions)) { loadedMissions = rm.missions; setMissions(rm.missions); }
        // ⭐ EL SELLO DE MARCA de ESTA marca. Si el servidor ya lo tiene, MANDA (así el look
        // le sigue entre equipos). Si no lo tiene pero el navegador sí, se SUBE una vez:
        // nadie pierde el look que ya había elegido a mano.
        try {
          const re = await window.MCNet.getEstilo();
          const e = (re && re.ok && re.estilo) || null;
          if (e && (e.plantilla || e.fuente || e.color || e.handle)) {
            if (e.plantilla) { setCarTplState(e.plantilla); try { localStorage.setItem("jarvis_carousel_template", e.plantilla); } catch (x) {} }
            if (e.fuente)    { setCarFontState(e.fuente);   try { localStorage.setItem("jarvis_carousel_font", e.fuente); } catch (x) {} }
            if (e.color)     { setCarColorState(e.color);   setCarColorApplied(e.color);   try { localStorage.setItem("jarvis_brand_color", e.color); } catch (x) {} }
            if (e.handle)    { setCarHandleState(e.handle); setCarHandleApplied(e.handle); try { localStorage.setItem("jarvis_brand_handle", e.handle); } catch (x) {} }
          } else if (re && re.ok && nicho == null) {
            // 🐛 CONTAMINACIÓN ENTRE MARCAS (cazado por la revisión, crítico):
            // las claves de localStorage NO llevan el nicho — son GLOBALES. Al entrar a una
            // marca SIN sello, esto subía como "suyo" el look de la marca visitada ANTES.
            // La marca B nacía con la identidad visual de A, sin que el usuario lo eligiera.
            // → La migración solo aplica a la marca POR DEFECTO (nicho == null): es la única
            //   que existía antes del multi-marca y la única cuyo look en el navegador es suyo.
            const local = {};
            try {
              const t = (localStorage.getItem("jarvis_carousel_template") || "").trim(); if (t) local.plantilla = t;
              const f = (localStorage.getItem("jarvis_carousel_font") || "").trim();     if (f) local.fuente = f;
              const c = (localStorage.getItem("jarvis_brand_color") || "").trim();       if (c) local.color = c;
              const h = (localStorage.getItem("jarvis_brand_handle") || "").trim();      if (h) local.handle = h;
            } catch (x) {}
            if (Object.keys(local).length) guardarSello(local);
          } else if (re && re.ok) {
            // Marca con nombre y SIN sello propio → arranca LIMPIA, con los valores por
            // defecto del dibujante. Sin esto heredaba visualmente el look de la marca
            // anterior (el caché global seguía cargado), aunque ya no se guardara.
            setCarTplState("perfil1");   setCarFontState("impacto");
            setCarColorState("");        setCarColorApplied("");
            setCarHandleState("");       setCarHandleApplied("");
            try {
              localStorage.setItem("jarvis_carousel_template", "perfil1");
              localStorage.setItem("jarvis_carousel_font", "impacto");
              localStorage.removeItem("jarvis_brand_color");
              localStorage.removeItem("jarvis_brand_handle");
            } catch (x) {}
          }
        } catch (e) {}
        selloReadyRef.current = true;   // desde aquí sí, los cambios del usuario se guardan
        let libreOn = false;
        try { const rf = await window.MCNet.flags(); if (rf && rf.ok) { if (Array.isArray(rf.skills)) setSkillIds(rf.skills); libreOn = !!rf.chatLibre; setChatLibre(libreOn); setCarStyleOn(!!rf.carStyle); setIgPublishOn(!!rf.igPublish); setCanaryOn(!!rf.canary); if (rf.igPublish) { try { const rs = await window.MCNet.igStatus(); if (rs && rs.ok) { setIgConn({ connected: !!rs.connected, username: rs.username || "", dias: (rs.diasRestantes == null ? null : rs.diasRestantes) });
          // El acceso a Instagram caduca a los 60 días y el servidor lo renueva solo al abrir
          // esto. Si aun así murió (más de 60 días sin abrir MC), NO se puede resucitar: hay
          // que reconectar. Se dice AQUÍ, al entrar — no a mitad de una publicación.
          if (rs.caducado) jarvis("Señor, su conexión con Instagram caducó por inactividad. Vuelva a conectarla en el chip de arriba y seguimos donde lo dejamos.", { sys: true });
        } } catch (e) {} } } } catch (e) {}
        avisaLlavesQueFaltan();
        await delay(400);
        const idx = firstUnansweredIdx(p);
        if (idx === -1) {
          setStage("ready");
          const saved = libreOn ? loadChatLS(nicho) : null;   // continúa la conversación previa DE ESTE NICHO (Fase 3)
          if (saved && saved.length) setMsgs(saved);
          const cand = findActivePackMission(loadedMissions);
          let offered = false;
          if (cand) {
            const inf = parsePack(cand);
            const res = await doneSkillsDeMision(cand.id);   // AUTORITATIVO (no el estudio truncado)
            if (res.ok) {
              const faltan = inf.plan.filter(function (s) { return !res.done[s]; });
              if (faltan.length) {
                offered = true;
                jarvis("Qué gusto tenerlo de vuelta, señor. Dejó un pack a medias: «" + inf.idea + "» (" + (inf.plan.length - faltan.length) + " de " + inf.plan.length + "). ¿Lo termino?", { resume: cand });
              } else {
                try { window.MCNet.updateMission(cand.id, { status: "done" }); } catch (e) {}   // ya estaba completo → cerrar, no ofrecer
              }
            }
          }
          if (!offered) {
            if (saved && saved.length) jarvis("Aquí seguimos, señor. ¿Qué le preparo?", { suggestions: true });   // recarga → termina en botones frescos, no en texto huérfano
            else jarvis("Qué gusto tenerlo de vuelta, señor. Ya conozco su marca. ¿Qué le preparo hoy?", { suggestions: true });
          }
        }
        else {
          setStage("discovery");
          // 🧠 LA CONVERSACIÓN NO SE PIERDE AL RECARGAR — tampoco a mitad de la entrevista.
          // Danny lo cazó en canary: recargó y el chat apareció vacío, solo con la última
          // pregunta. Su lectura fue exacta: «mostrarme la conversación anterior es mejor
          // para recordar y pensar que JARVIS realmente se acuerda».
          // La conversación YA se guardaba (el efecto de arriba lo hace siempre); el fallo
          // era que solo se recuperaba cuando la marca estaba TERMINADA. A media entrevista
          // se ignoraba — justo cuando más se nota, porque uno ni ve lo que ya contestó.
          // NO se filtra por el interruptor del chat libre: recuperar lo que uno mismo
          // acaba de responder no es una función extra, es no tener amnesia.
          const previo = loadChatLS(nicho);
          if (previo && previo.length) setMsgs(previo);
          // `bienvenida:true` lo marca para poder reescribirlo si el usuario RENOMBRA
          // la marca (ver el efecto de abajo). Sin esa marca, el saludo se quedaba con
          // el nombre viejo para siempre. (Espacios dentro de « » para que resalte.)
          const esSaludo = (idx === 0 && nicho);
          const preguntaAhora = esSaludo
            ? "Marca nueva, señor — « " + (nichoNombre || "esta marca") + " ». Construyámosla de cero. " + PREG_NICHO
            : textoPregunta(PREGUNTAS[idx], p);   // la última pregunta depende del objetivo ya respondido
          // Si lo recuperado YA termina con esa misma pregunta, no se repite: al recargar
          // se vería dos veces seguidas y parecería que JARVIS se traba.
          // 🐛 ANTES esto se comparaba POR EL TEXTO y falló en cuanto cambiamos la
          // redacción de una pregunta: Danny vio las DOS versiones seguidas, la vieja
          // y la nueva, como si JARVIS se hubiera trabado. Ahora cada pregunta va
          // MARCADA con su clave (`preg`) y se compara por eso — el texto puede
          // cambiar mil veces y sigue sabiendo que es la misma pregunta.
          // Y si la redacción cambió, se REEMPLAZA en vez de duplicar.
          const clave = esSaludo ? "__saludo" : PREGUNTAS[idx].key;
          const ultimo = (previo && previo.length) ? previo[previo.length - 1] : null;
          // Respaldo por texto para las conversaciones guardadas ANTES de esta marca:
          // sin él, a quien ya tenía una charla a medias le saldría la pregunta otra vez.
          const mismaPregunta = !!(ultimo && ultimo.from === "j"
            && (ultimo.preg ? ultimo.preg === clave : ultimo.text === preguntaAhora));
          if (mismaPregunta) {
            // misma pregunta, ¿redacción nueva? → se pone al día en su sitio
            if (ultimo.text !== preguntaAhora) {
              setMsgs((prev) => prev.map((m, i) => (i === prev.length - 1 ? Object.assign({}, m, { text: preguntaAhora }) : m)));
            }
          } else {
            jarvis(preguntaAhora, esSaludo ? { bienvenida: true, preg: clave } : { preg: clave });
          }
        }
        chatReadyRef.current = true;   // ya se leyó lo guardado → desde aquí SÍ se guarda
      })().catch(function () {
        // Si el arranque se cae a medias (red, servidor), la llave se abre igual:
        // dejar de guardar en silencio sería peor que guardar algo incompleto.
        chatReadyRef.current = true;
      });
    }, [jarvis]);

    // 🏷️ SI RENOMBRA LA MARCA, EL SALUDO SE PONE AL DÍA.
    // Danny lo cazó en vivo: creó « Repostería », la renombró a « Motivación », y la
    // pastilla de arriba cambió pero el saludo del chat seguía diciendo Repostería.
    // Causa: el texto se ESCRIBE una sola vez al arrancar, con el nombre de ese momento;
    // renombrar no vuelve a montar el panel (la marca es la misma, solo cambió su nombre).
    // No se reinicia el chat a propósito: renombrar no debe borrarle la conversación.
    useEffect(() => {
      if (!nicho || !nichoNombre) return;
      setMsgs((prev) => {
        let cambio = false;
        const out = prev.map((m) => {
          if (!m || !m.bienvenida) return m;
          const nuevo = "Marca nueva, señor — « " + nichoNombre + " ». Construyámosla de cero. " + PREG_NICHO;
          if (m.text === nuevo) return m;
          cambio = true;
          return Object.assign({}, m, { text: nuevo });
        });
        return cambio ? out : prev;   // sin cambios → mismo array (no re-pinta de más)
      });
    }, [nicho, nichoNombre]);

    const curQ = (stage === "discovery" && perfil) ? PREGUNTAS[firstUnansweredIdx(perfil)] : null;

    async function responder(rawValue, displayText) {
      if (thinking || packRun) return;
      const q = curQ; if (!q) return;
      pushMsg({ from: "u", text: displayText });
      setInput(""); setMulti([]);
      // `escribeEn` deja que una pregunta guarde en OTRO campo: el subtipo pisa el
      // nicho con el oficio específico, y así nada de lo que hay debajo cambia.
      const patch = {}; patch[q.escribeEn || q.key] = rawValue;
      const np = Object.assign({}, perfil, patch);
      setPerfil(np);
      try { window.MCNet.setPerfil(patch); } catch (e) {}
      const nextIdx = firstUnansweredIdx(np);
      await pace();
      // 🗣️ Primero el ECO (le devuelve lo que dijo), después la siguiente pregunta.
      // Van como DOS mensajes a propósito: uno solo con todo pegado se lee como
      // formulario; separados se leen como alguien que escucha y luego pregunta.
      const eco = ecoDe(q.key, displayText, rawValue);
      if (eco) jarvis(eco);
      if (nextIdx === -1) {
        setStage("ready");
        const resumen = resumenMarca(np);
        await pace();
        // Tras la entrevista se OFRECE la ficha del negocio (no se impone): es el momento
        // en que él ya está contándole cosas, y cada dato suyo vale más que cualquier
        // regla que podamos escribir. Medido: −33 puntos en «láminas sin nada comprobable».
        if (resumen) jarvis("Entonces su marca es: " + resumen + ". Con eso ya le escribo a su medida, señor.\n\nSi me deja hacerle unas preguntas cortas sobre su negocio —sus precios, sus plazos, lo que vende— dejo de suponer y todo lo que le escriba saldrá con SUS datos.", { fichaOferta: true });
        else jarvis("Listo, señor. Ya conozco su marca.\n\nSi me deja hacerle unas preguntas cortas sobre su negocio, dejo de suponer y todo lo que le escriba saldrá con SUS datos.", { fichaOferta: true });
      }
      else { jarvis(textoPregunta(PREGUNTAS[nextIdx], np), { preg: PREGUNTAS[nextIdx].key }); }
    }

    // ⭐⭐ LA FICHA DE SU NEGOCIO — la palanca más grande, y está medida: con los datos
    //    reales del dueño en el prompt, las láminas sin nada comprobable cayeron del 50 %
    //    al 16,7 % y la media de fallos de 12,3 % a 9,9 %. El texto deja de ser folleto.
    // ⚠️ JARVIS pregunta, NO rellena. Si él propusiera las respuestas y el usuario
    //    aprobara sin leer —y aprobaría sin leer—, tendríamos invenciones guardadas como
    //    si fueran suyas, repetidas luego en todas sus piezas como si fueran verdad.
    //    La pregunta la adapta la IA al oficio; el DATO lo pone siempre él.
    async function abrirFicha() {
      if (busy || packRun || ficha) return;
      setThinking({ label: "Preparando sus preguntas, señor…" });
      let r = null;
      try { r = await window.MCNet.hojaPreguntas(); } catch (e) {}
      setThinking(null);
      const qs = (r && r.ok && Array.isArray(r.preguntas)) ? r.preguntas.filter((q) => q && q.p) : [];
      if (!qs.length) {
        // Sin preguntas puede ser un fallo O que ya lo sepa todo: se distingue.
        if (r && r.ok) jarvis("Ya tengo anotado todo lo que necesito de su negocio, señor. Puede verlo en «Lo que sé de usted».");
        else manejarError(r);
        return;
      }
      // ⭐ DOS RONDAS. La 1 es lo imprescindible; la 2, el detalle fino que MIDE MUCHO
      //    (con 8 datos el 42 % de las piezas sale sin fallos; con 17, el 58 %, y «le
      //    enseña el oficio a quien compra» cayó del 25 % al 0 %). Pero se OFRECE al
      //    terminar la primera, no se encadena: quince preguntas seguidas espantan.
      // ⚠️ ARREGLADO EL 28-jul, VIÉNDOLO CON DANNY. El corte por rondas es correcto la
      //    PRIMERA vez (quince preguntas de golpe espantan), pero cuando él viene a pedir
      //    «cuénteme más» estaba al revés: su ficha ya tenía cubierta la ronda 1, así que
      //    de las 8 preguntas que sobrevivían al filtro solo le mostraba 2 — y las dos
      //    REPETIDAS. Las que de verdad hacían falta —sus SEÑALES y lo que YA VA INCLUIDO—
      //    vienen marcadas como ronda 2 («el detalle fino») y se quedaban fuera siempre.
      //    Ahora: si la ronda 1 trae menos de tres preguntas nuevas, es que ya está cubierta
      //    → se le dan TODAS, empezando por las que más pagan.
      const r1 = qs.filter((q) => q.r !== 2), r2 = qs.filter((q) => q.r === 2);
      // ⭐ las dos que se midieron (sus señales / lo que va incluido) van SIEMPRE primero:
      //    son las que el modo Educar necesita y las que hacen que deje de inventar.
      const CLAVE = /se[ñn]al|se (?:le )?nota|se da cuenta|ya (?:va|van) incluid|se paga aparte|se cobra aparte/i;
      const primero = (a, b) => (CLAVE.test(b.p || '') ? 1 : 0) - (CLAVE.test(a.p || '') ? 1 : 0);
      const lote = (r1.length >= 3 ? r1 : qs).slice().sort(primero);
      setFicha({ qs: lote, i: 0, resto: r1.length ? r2 : [] });
      setStage("ficha");
      await pace();
      {
        const s0 = cuantoSabe(hechos);
        jarvis("Perfecto, señor. Son " + lote.length + (lote.length === 1 ? " pregunta" : " preguntas") + " cortas" + (s0.pct ? " — va por el " + s0.pct + "% y con estas sube" : " y con sus respuestas dejo de suponer nada") + ". Si alguna no va con su negocio, dígamelo y sigo.");
      }
      await pace();
      jarvis(lote[0].p, { fichaQ: lote[0].k });
    }

    async function avanzarFicha(guardar, texto) {
      const f = ficha; if (!f) return;
      const q = f.qs[f.i];
      if (guardar && texto) {
        pushMsg({ from: "u", text: texto });
        setInput("");
        // La ÚLTIMA es siempre su historia: se marca para que el guardián no se la
        // vuelva a pedir al generar (misma bandera que ya usa el chat libre).
        const esHistoria = f.i === f.qs.length - 1 || /histor|empez|c[oó]mo naci/i.test(String(q.p));
        const anotado = String(q.p).replace(/\?+$/, '') + ': ' + texto;
        try { await window.MCNet.addHecho(anotado, { esHistoria: esHistoria }); } catch (e) {}
        // ⭐ Y SE APUNTA TAMBIÉN EN LOCAL, para que la barra suba AL INSTANTE. Sin esto el
        //    porcentaje se quedaría clavado toda la ficha y no serviría de nada: lo que
        //    motiva a terminar es VER que avanza, que es justo lo que pidió Danny.
        setHechos((prev) => (Array.isArray(prev) ? prev.concat([{ t: anotado }]) : [{ t: anotado }]));
      } else {
        // ⚠️ el texto dice «No aplica», no «Saltar»: es lo que el botón ofrece ahora, y
        //    saltar por no aplicar es legítimo — saltar por dejadez es lo que se quitó.
        pushMsg({ from: "u", text: "No aplica a mi negocio" });
        setInput("");
      }
      const sig = f.i + 1;
      if (sig >= f.qs.length) {
        const resto = f.resto || [];
        setFicha(null); setStage("ready");
        await pace();
        if (resto.length) {
          // Se ofrece la segunda ronda con el porqué por delante y sin presionar: quien
          // quiera crear ya, crea; el botón queda en «Lo que sé de usted» para siempre.
          // ⭐ AQUÍ SE DECIDE, así que aquí hay que enseñarle LO QUE GANA. Danny: «debe salir
          //    el porcentaje de cómo va, para que sepa que avanza — si no, no lo sabrá y se
          //    decidirá por abandonar». El contador «5 de 7» dice DÓNDE está; el porcentaje
          //    dice lo que se LLEVA, que es lo que mueve a terminar. Y se le nombra lo que
          //    falta, que es más concreto que un número suelto.
          const s = cuantoSabe(hechos);
          const falta = s.faltan.slice(0, 2).map((f) => f.k).join(" y ");
          jarvis("Listo, señor. Con esto ya le conozco al " + s.pct + "%.\n\nMe quedan " + resto.length
            + " preguntas de detalle" + (falta ? " — y ahí está " + falta : "")
            + ". Son las que hacen que una pieza suene a SU negocio y no a cualquiera del rubro. ¿Seguimos o lo dejamos para otro rato?", { fichaResto: resto });
        } else {
          jarvis("Listo, señor. Con esto ya no tengo que suponer nada de su negocio: lo que le escriba va a salir con SUS datos. ¿Qué le preparo?", { suggestions: true });
        }
        return;
      }
      setFicha({ qs: f.qs, i: sig, resto: f.resto });
      await pace();
      jarvis(f.qs[sig].p, { fichaQ: f.qs[sig].k });
    }

    // 🪦 AQUÍ VIVÍA dejarFicha(), el «Dejarlo para después». Se quitó el 28-jul: era una
    //    puerta de salida que no aportaba nada — si quiere parar, cierra el panel. Y por
    //    lo que dijo Danny: «si uno quiere tener un buen resultado, no tienes que
    //    incitarle a que abandone». La función se borra entera para que no quede código
    //    muerto llamando a un botón que ya no existe.

    function elegirSkill(id) {
      if (thinking || packRun) return;
      setChosen(id);
      pushMsg({ from: "u", text: SKILLS_SUG.filter((s) => s.id === id).map((s) => s.label)[0] || id, ui: true });   // etiqueta de botón
      if (id === "reel_script") {   // guion → primero elige la DURACIÓN (el punto dulce es <2:30)
        jarvis("Con gusto, señor. ¿De cuánto lo quiere?", { durChips: true });
        return;
      }
      setStage("idea");
      jarvis("Perfecto. Escríbame aquí abajo la idea o tema para " + suSkill(id) + " " + (SKILL_SHORT[id] || "pieza") + " — o si no la tiene, toque «💡 deme ideas», señor.");
    }
    // El usuario eligió la duración del guion → guarda + pide la idea.
    function elegirDuracion(val, label) {
      if (thinking || packRun) return;
      setGuionDur(val);
      pushMsg({ from: "u", text: label, ui: true });   // etiqueta de botón (duración del guion)
      if (pendingGuionIdea) {   // la idea YA vino (chat libre) → generar directo con esa duración
        const idea = pendingGuionIdea;
        setPendingGuionIdea("");
        (async () => { setThinking({ label: "Atlas está redactando su guion…" }); await delay(10000); setThinking(null); await generarCon(idea, "reel_script", val); })();
        return;
      }
      setStage("idea");
      jarvis("Perfecto, señor. Escríbame aquí abajo la idea o tema para su guion — o si no la tiene, toque «💡 deme ideas».");
    }

    // Reintenta SOLO ante el guardián de ritmo del server (reason "pace"): espera su
    // ventana real y reintenta, sin molestar al usuario. Cualquier otro resultado
    // (éxito o error real) sale a la primera. Compartido por TODAS las llamadas de IA.
    // ⚠️ REINTENTABA SOLO EL FRENO NUESTRO, NO EL DEL PROVEEDOR — y el aviso MENTÍA. El texto de
    //    'rate_limit' promete «esperemos unos segundos y lo reintento», pero esta función rompía
    //    en cualquier razón que no fuera 'pace': en el camino de la pieza suelta ese reintento no
    //    existía. Danny pidió un carrusel, le salió el aviso y esperó dos minutos a que ocurriera
    //    algo que nadie había programado. El camino del PACK sí reintentaba el 429 desde el
    //    28-jul (ver generarPiezaPack): dos caminos con dos conductas para el MISMO error, y el
    //    usuario no tiene por qué saber por cuál va.
    //    Es literalmente el fallo que ya está escrito unas líneas más abajo, en textoError: un
    //    mensaje falso hace que el usuario espere en vano y pierda la confianza.
    // ⚠️ El 429 es POR MINUTO: esperar los 8s del freno propio no lo arregla, hay que esperar de
    //    verdad. Y por eso se permite UN solo reintento — dos serían casi un minuto de espera
    //    muda, y a partir de ahí es más honesto devolver el error y que él decida.
    async function conReintentoPace(hacer, label) {
      let r = null;
      for (let intento = 0; intento < 3; intento++) {
        try { r = await hacer(); } catch (e) { r = { ok: false, reason: "network" }; }
        const razon = r && r.reason;
        if (razon !== "pace" && razon !== "rate_limit") break;
        const esPace = razon === "pace";
        if (!esPace && intento >= 1) break;                       // el 429: un intento más y ya
        const waitMs = esPace ? ((r.retryMs || 8000) + 400) : 25000;
        setThinking({ label: esPace ? (label || "Un momento, señor — sigo con lo anterior…")
          : "La cuota de su llave pide un respiro… reintento en " + Math.round(waitMs / 1000) + "s, señor." });
        await delay(waitMs);
      }
      return r;
    }

    // Genera la pieza del formato elegido con la idea dada (texto libre o idea sugerida).
    // ⭐⭐ AVISAR ANTES DE GASTAR (31-jul, lo pidió Danny dos veces). El aviso de parecido que
    //    ya existía llega DESPUÉS: cuando la llamada y las 5 imágenes ya están pagadas. Él:
    //    «¿no debería salir antes y preguntarme si continúo?». Sí.
    //    Se compara solo lo que TECLEA contra los títulos que ya tiene — instantáneo, gratis y
    //    sin IA — y se le pregunta con dos botones. El de después se queda como red.
    // ⚠️ `saltar` = ya contestó que sí. Sin esa puerta, decir «sí, hazla» volvería a preguntar
    //    para siempre y no se generaría nunca.
    async function generarCon(idea, skillOverride, durOverride, saltar) {
      if (packRun) return;
      const skill = skillOverride || chosen;
      const q = (idea || "").trim();
      if (q.length < 8) { jarvis("Deme un poco más de detalle, señor (una frase basta)."); return; }
      if (!saltar && SKILL_KIND[skill]) {
        try {
          const pv = await window.MCNet.parecidoPrevio(q, SKILL_KIND[skill]);
          if (pv && pv.ok && pv.parecido && pv.parecido.title) {
            pushMsg({ from: "j", text: "Señor, ya tiene una pieza sobre esto: «" + pv.parecido.title + "». "
              + "Instagram penaliza repetir lo mismo, y hacerla otra vez le gasta llave e imágenes. ¿La hago igual?",
              parecidoConfirm: { idea: q, skill: skill, dur: durOverride } });
            return;
          }
        } catch (e) {}   // si la comprobación falla, NO se estorba: se genera y ya
      }
      const short = SKILL_SHORT[skill] || "pieza";
      const agent = AGENT_BY_SKILL[skill] || "Atlas";
      setThinking({ label: agent + (agent === "Karen" ? " está diseñando " : " está redactando ") + suSkill(skill) + " " + short + "…" });
      const dur = (skill === "reel_script") ? (durOverride || guionDur || (perfil && perfil.duracion_reel) || "medio") : (perfil && perfil.duracion_reel);
      const ctxChat = buildContextoChat(msgs, q);   // ⭐ lo que el usuario ha dicho (solo sus palabras)
      // El server frena a 1 generación cada ~8s (protege la llave BYOK). Si el usuario
      // encadena rápido, conReintentoPace espera esa ventana y reintenta solo (invisible).
      const r = await conReintentoPace(
        function () { return Promise.all([window.MCNet.generate({ skill: skill, ideaBase: q, tone: perfil && perfil.voz, duracion: dur, contextoChat: ctxChat }), delay(1500)]).then(function (a) { return a[0]; }); },
        agent + " termina lo anterior y sigue con " + suSkill(skill) + " " + short + "…"
      );
      setThinking(null);
      if (r && r.ok && r.deliverable) {
        setArchive((a) => [r.deliverable].concat(a));   // al estudio, arriba
        setOpenId(r.deliverable.id);
        jarvis("✓ Listo, señor — " + suSkill(skill) + " " + short + " " + estaSkill(skill) + " en su estudio, a la derecha. 👉");
        // ⭐ EL AVISO DE PARECIDO (31-jul, la «C»). Se dice CON CUÁL se parece, no un porcentaje
        //    a secas: el número no le sirve para decidir, ver la pieza sí — la abre, la mira y
        //    resuelve en tres segundos. Y es una PREGUNTA, nunca un bloqueo: él a veces quiere
        //    repetir un tema que funcionó, y eso la máquina no puede saberlo.
        if (r.parecido && r.parecido.title) {
          await delay(250);
          jarvis("Un aviso, señor: esta pieza se parece bastante a «" + r.parecido.title + "», que ya tiene en su estudio. "
            + "Instagram penaliza publicar lo mismo dos veces. Si era a propósito, adelante; si no, dígame «hazla desde otro ángulo» y se la cambio.");
        }
        await avisaCifras(r);
        await delay(250);
        setStage("ready");
        jarvis("¿Le preparo algo más?", { suggestions: true });
      } else if (pedirHistoria(r)) { setStage("ready"); }   // no es error: JARVIS le pide SU historia
      else { manejarError(r); setStage("ready"); }
    }
    async function generar() {
      if (packRun) return;
      const q = input.trim();
      if (q.length < 8) { jarvis("Deme un poco más de detalle, señor (una frase basta)."); return; }
      pushMsg({ from: "u", text: q });
      setInput("");
      if (chosen === "pack") await armarPack(q);
      else await generarCon(q);
    }
    // "No sé, deme ideas" → JARVIS propone ideas del NICHO del usuario (content_ideas,
    // EFÍMERO = no ensucia el estudio) → salen como chips clicables.
    async function demeIdeas(silent) {
      if (thinking || packRun) return;
      if (silent !== true) pushMsg({ from: "u", text: "💡 No sé, deme ideas", ui: true });
      setThinking({ label: "JARVIS está pensando ideas de su nicho…" });
      const r = await conReintentoPace(
        function () { return Promise.all([window.MCNet.generate({ skill: "content_ideas", ideaBase: "", ephemeral: true, contextoChat: buildContextoChat(msgs) }), delay(1500)]).then(function (a) { return a[0]; }); },
        "JARVIS sigue pensando sus ideas, señor…"
      );
      setThinking(null);
      const ideas = (r && r.ok && r.payload && Array.isArray(r.payload.ideas))
        ? r.payload.ideas.map(function (x) { return x && x.titulo ? String(x.titulo) : ""; }).filter(Boolean).slice(0, 5) : [];
      if (ideas.length) { jarvis("Aquí tiene ideas de su nicho, señor — toque la que le guste:", { ideas: ideas }); }
      else { manejarError(r); }
    }
    // el usuario tocó una idea sugerida → genera el formato elegido con esa idea.
    async function usarIdea(titulo) {
      if (thinking || packRun) return;
      pushMsg({ from: "u", text: titulo });
      if (chosen === "pack") await armarPack(titulo);
      else await generarCon(titulo);
    }

    // ⭐⭐ MOVERSE ENTRE VERSIONES — las flechas ◀ ▶ (31-jul).
    //    `paso` = -1 (atrás) o +1 (adelante). La línea de tiempo la calcula el SERVIDOR y
    //    devuelve en qué posición estamos: aquí no se recalcula nada, para que las flechas y
    //    lo que restaura el server no puedan discrepar nunca.
    // ⚠️ Se avisa cuando ya no hay más — sin decir nada, el usuario pulsa y cree que se rompió.
    async function versionar(d, paso) {
      if (verBusy) return;
      setVerBusy(d.id);
      try {
        const info = await window.MCNet.getDraft(d.id);
        if (!info || !info.ok) { jarvis("No pude leer las versiones de esa pieza, señor. ¿Lo intento otra vez?"); return; }
        const total = info.versionesTotal || 1, actual = (info.versionActual == null ? total - 1 : info.versionActual);
        const destino = actual + paso;
        // Con las flechas apagadas esto ya casi no debería verse — se queda como red por si el
        // estado de la pantalla se quedó viejo (otra pestaña, un cambio a mitad).
        if (destino < 0)      { setVerInfo((v) => Object.assign({}, v, { [d.id]: { index: actual, total: total } })); jarvis("Ya está en la primera versión, señor — no hay nada más atrás."); return; }
        if (destino >= total) { setVerInfo((v) => Object.assign({}, v, { [d.id]: { index: actual, total: total } })); jarvis("Ya está en la última versión, señor — no hay nada más adelante."); return; }
        const r = await window.MCNet.restoreDraft(d.id, destino);
        if (!r || !r.ok || !r.deliverable) { jarvis("No pude volver a esa versión, señor. ¿Lo intento otra vez?"); return; }
        setArchive((a) => a.map((x) => (x.id === d.id ? r.deliverable : x)));
        setVerInfo((v) => Object.assign({}, v, { [d.id]: { index: (r.index == null ? destino : r.index), total: (r.total || total) } }));
        jarvis((paso < 0 ? "Volví a la versión " : "Avancé a la versión ") + (destino + 1) + " de " + total + ", señor. 👉");
      } catch (e) { jarvis("Se me cortó al cambiar de versión, señor. ¿Lo intento otra vez?"); }
      finally { setVerBusy(null); }
    }
    // Al ABRIR una pieza que se ha tocado alguna vez, se pregunta en qué versión está — así las
    // flechas nacen ya encendidas o apagadas, sin que el usuario tenga que pulsarlas para saberlo.
    // Es una lectura de base de datos, sin IA: no gasta llave ni cuota.
    useEffect(function () {
      const d = (archive || []).filter(function (x) { return x.id === openId; })[0];
      if (!openId || !d || (d.revision_count || 0) <= 0 || verInfo[openId]) return;
      let vivo = true;
      window.MCNet.getDraft(openId).then(function (r) {
        if (!vivo || !r || !r.ok) return;
        setVerInfo(function (v) { return Object.assign({}, v, { [openId]: { index: (r.versionActual == null ? (r.versionesTotal || 1) - 1 : r.versionActual), total: r.versionesTotal || 1 } }); });
      }).catch(function () {});
      return function () { vivo = false; };
    }, [openId, archive]);

    // ---------- EL PACK: 1 idea → un KIT de piezas del MISMO mensaje (cohesión) ----------
    function elegirPack() {
      if (thinking || packRun) return;
      setChosen("pack");
      setPackCombo(packMenu);
      pushMsg({ from: "u", text: "🎁 Un pack completo", ui: true });
      setStage("idea");
      // ⚠️ La lista de piezas iba ESCRITA A MANO con las 4. Ahora que el kit se ajusta a sus
      //    redes, dejarla fija prometería una pieza que no va a caer — el mismo desajuste
      //    «prometo N, entrego N» que perseguimos en el carrusel. Se dice lo que de verdad viene.
      jarvis("Excelente, señor. Un pack son varias piezas que cuentan EL MISMO mensaje, coordinadas entre sí — no piezas sueltas. Dígame en una frase el tema y yo armo el kit: " + listaNatural(packMenu.map(function (sk) { return PIEZA_EN_FRASE[sk] || sk; })) + ". Tomará un par de minutos; irá viendo cada pieza caer en su estudio. 👉");
    }

    // genera UNA pieza del pack (ángulo como context + mission_id), con reintentos ante cuota.
    async function generarPiezaPack(skill, idea, angText, missionId, attempt) {
      attempt = attempt || 0;
      let r = null;
      try {
        r = await window.MCNet.generate({
          skill: skill, ideaBase: idea, context: angText, title: idea,
          mission_id: missionId, tone: perfil && perfil.voz, duracion: perfil && perfil.duracion_reel,
          contextoChat: buildContextoChat(msgs, idea),   // ⭐ lo que el usuario ha dicho
        });
      } catch (e) { r = { ok: false, reason: "network" }; }
      // Y en el PACK también: son 3-4 piezas de golpe y es donde menos se revisa cada una.
      if (r && r.ok && r.deliverable) { setArchive((a) => [r.deliverable].concat(a)); await avisaCifras(r); return true; }
      const reason = r && r.reason;
      // el ángulo ya pasó el filtro, así que aquí es raro — pero si llega, se dice la
      // verdad en vez de contarlo como un fallo más de cuota.
      if (reason === "falta_historia") { faltaHistRef.current = r; return false; }
      // cuota (server 8s), 429 del proveedor o fallo transitorio → esperar y reintentar (máx 2)
      if (attempt < 2 && (reason === "pace" || reason === "rate_limit" || reason === "ai_failed" || reason === "network")) {
        const waitMs = reason === "pace" ? (((r && r.retryMs) || 8000) + 2000) : 25000;
        // Y aquí SÍ es la cuota o un tropiezo pasajero: la etiqueta decía lo mismo también con
        // `ai_failed` y `network`, que no tienen nada que ver con la cuota.
        setThinking({ label: (reason === "ai_failed" || reason === "network")
          ? "Se me ha caído la conexión un momento… reintento en " + Math.round(waitMs / 1000) + "s, señor."
          : "La cuota de su llave pide un respiro… reintento en " + Math.round(waitMs / 1000) + "s, señor." });
        await delay(waitMs);
        return generarPiezaPack(skill, idea, angText, missionId, attempt + 1);
      }
      // ⭐⭐ EL PACK LE ECHABA LA CULPA A «LA CUOTA» PASE LO QUE PASE. Había UN solo mensaje para
      //   TODOS los fallos: llave muerta, cupo del día, falta de llave, respuesta corrupta,
      //   tiempo agotado, sin cuenta. El señor con la llave REGENERADA leía «la cuota pide un
      //   respiro», se iba a esperar —horas— y al volver le decían exactamente lo mismo. La
      //   espera no arreglaba nada porque el problema nunca fue la cuota: era su llave. Y el
      //   texto estaba escrito a mano ahí, sin pasar por `textoError`, que SÍ distingue.
      //   Se guarda el motivo para que quien lo cuente diga la verdad.
      packFalloRef.current = r || { reason: "desconocido" };
      return false;
    }

    // ⭐ 'falta_historia' NO es un error: es JARVIS diciendo "esa es SU historia y no me
    // la ha contado". En el chat lo natural es que la PREGUNTE aquí mismo; cuando el
    // usuario conteste, la regla del propio chat la guarda y crea la pieza. Sin esto,
    // el chat lo trataba como fallo y el pack moría con un mensaje falso de cuota.
    function pedirHistoria(r) {
      if (!r || r.reason !== "falta_historia") return false;
      jarvis(r.pregunta || "Con gusto, señor — pero esa es SU historia y aún no me la ha contado. ¿Qué pasó exactamente? Cuéntemelo en dos o tres frases y la escribo con sus palabras.");
      return true;
    }

    // genera el ÁNGULO (efímero) con reintentos ante cuota → un choque de 8s NO mata el pack entero.
    async function generarAngulo(idea, attempt) {
      attempt = attempt || 0;
      let r = null;
      try { r = await window.MCNet.generate({ skill: "pack_angle", ideaBase: idea, ephemeral: true }); }
      catch (e) { r = { ok: false, reason: "network" }; }
      if (r && r.ok && r.payload) return r.payload;
      const reason = r && r.reason;
      if (reason === "falta_historia") { faltaHistRef.current = r; return null; }   // que hable armarPack
      if (attempt < 2 && (reason === "pace" || reason === "rate_limit" || reason === "ai_failed" || reason === "network")) {
        const waitMs = reason === "pace" ? (((r && r.retryMs) || 8000) + 2000) : 20000;
        setThinking({ label: "La cuota pide un respiro… reintento el ángulo en " + Math.round(waitMs / 1000) + "s, señor." });
        await delay(waitMs);
        return generarAngulo(idea, attempt + 1);
      }
      return null;
    }

    // corre una lista de piezas (secuencial, espaciado, reintentos). COMPARTIDO por armar y retomar.
    async function correrPiezas(missionId, idea, angText, skills, yaHechas, total, initialGapMs) {
      let hechas = yaHechas;
      for (let i = 0; i < skills.length; i++) {
        const sk = skills[i];
        const agent = AGENT_BY_SKILL[sk] || "Atlas";
        const verbo = agent === "Karen" ? "diseñando su carrusel" : "redactando " + suSkill(sk) + " " + (SKILL_SHORT[sk] || "pieza");
        setThinking({ label: "Pieza " + (hechas + 1) + " de " + total + " — " + agent + " está " + verbo + "…" });
        if (i === 0 && initialGapMs) await delay(initialGapMs);   // respiro tras el ángulo → la 1ª no choca con el piso de 8s
        const ok = await generarPiezaPack(sk, idea, angText, missionId);
        if (!ok) {
          setThinking(null);
          jarvis(motivoPackCortado(hechas, total));
          setPackRun(null); setStage("ready"); jarvis("¿Le preparo algo más mientras?", { suggestions: true });
          return false;
        }
        hechas++;
        // "lista" estaba fijo → salía "Guion lista", "Carrusel lista", "Texto del post lista".
        // 4 de las 6 etiquetas son masculinas: en español canta. KIND_READY lo concuerda.
        // `sys`: el avance pieza-a-pieza se ve EN VIVO (es lo bonito de ver caer el pack),
        // pero mañana no dice nada — y eran 4 burbujas por pack acumulándose para siempre.
        // El resumen final ("Su pack está completo") SÍ se queda: eso sí es un hito.
        jarvis("✓ " + hechas + " de " + total + " — " + (KIND_LABEL[SKILL_KIND[sk]] || "pieza") + " " + (KIND_READY[SKILL_KIND[sk]] || "lista") + " en su estudio.", { sys: true });
        // el indicador SIGUE activo durante el gap → busy queda true → los chips viejos NO se re-activan (anti doble-pack)
        if (i < skills.length - 1) { setThinking({ label: "Preparando la siguiente pieza, señor…" }); await delay(PACK_GAP_MS); }
        else setThinking(null);
      }
      return true;
    }

    async function armarPack(idea, comboArg) {
      if (packStartingRef.current || packRun) return;   // latch síncrono: cierra la ventana antes de setPackRun (anti doble-pack por doble-clic)
      packStartingRef.current = true;
      const q = (idea || "").trim();
      if (q.length < 8) { packStartingRef.current = false; jarvis("Deme un poco más de detalle, señor (una frase basta)."); return; }
      // ⚠️ packCombo NACE con las 4, antes de que el perfil haya cargado. Quien dispare el pack
      //    sin pasar por elegirPack colaría una pieza sin casilla donde publicarse — así que el
      //    plan se cruza con packMenu SIEMPRE. Si el cruce quedara vacío, manda packMenu.
      const pedido = (comboArg && comboArg.length ? comboArg : (packCombo && packCombo.length ? packCombo : packMenu));   // combo explícito (no depender del estado async)
      const cruzado = pedido.filter(function (x) { return packMenu.indexOf(x) >= 0; });
      const plan = (cruzado.length ? cruzado : packMenu).slice(0, MAX_PACK_PIEZAS);
      setStage("pack_running");
      // 1) crear el CONTENEDOR (misión) ANTES del ángulo → reanudable si algo truena
      let mission = null;
      try {
        const rm = await window.MCNet.createMission(q, JSON.stringify({ v: "pack1", idea: str(q).slice(0, 200), plan: plan, angulo: null }));
        if (rm && rm.ok && rm.mission) mission = rm.mission;
      } catch (e) {}
      if (!mission) { packStartingRef.current = false; jarvis("No pude iniciar su pack, señor — probemos de nuevo en un momento."); setStage("ready"); jarvis("¿Le preparo algo más?", { suggestions: true }); return; }
      const missionId = mission.id;
      setMissions((ms) => [mission].concat(ms));
      setPackRun(missionId);
      packStartingRef.current = false;   // de aquí en adelante packRun protege
      // 2) el ÁNGULO (1 IA, efímero) — el hilo conductor de TODO el pack
      setThinking({ label: "JARVIS está definiendo el ángulo de su pack…" });
      const angRaw = await generarAngulo(q);
      setThinking(null);
      if (!angRaw) {
        // ⭐ Antes salía SIEMPRE "la cuota está ocupada" — mentira cuando el motivo real
        // es que le falta SU historia. Un mensaje falso hace que el usuario reintente
        // en vano y pierda la confianza en el departamento.
        const fh = faltaHistRef.current; faltaHistRef.current = null;
        if (fh) jarvis(fh.pregunta || "Esa es SU historia, señor, y aún no me la ha contado. Cuéntemela en dos o tres frases y le armo el pack entero con ella.");
        else jarvis("La cuota de su llave está ocupada, señor — no pude definir el ángulo. Probemos su pack en un minuto.");
        setPackRun(null); setStage("ready");
        if (!fh) jarvis("¿Le preparo algo más mientras?", { suggestions: true });
        return;
      }
      const ang = compactAngle(angRaw) || angRaw;   // acota los campos → el JSON de la misión no revienta los 2000 chars
      const angText = angleToText(ang);
      // persistir el ángulo (ya acotado) en la misión → reanudar (fase B) con el MISMO ángulo
      let objDone = JSON.stringify({ v: "pack1", idea: str(q).slice(0, 200), plan: plan, angulo: ang });
      if (objDone.length > 2000) objDone = JSON.stringify({ v: "pack1", idea: str(q).slice(0, 200), plan: plan, angulo: null });   // salvavidas: nunca guardar JSON truncado
      try { window.MCNet.updateMission(missionId, { objective: objDone }); } catch (e) {}
      setMissions((ms) => ms.map((m) => m.id === missionId ? Object.assign({}, m, { objective: objDone }) : m));
      jarvis("El ángulo de su pack, señor:\n\n« " + str(ang.mensaje_central) + " »\n\nGancho: " + str(ang.gancho) + "\n\nAhora armo cada pieza sobre este hilo — irán cayendo a su estudio 👉");
      // 3) generar las piezas (secuencial). Gap inicial 11s → la 1ª no choca con el ángulo.
      const okAll = await correrPiezas(missionId, q, angText, plan, 0, plan.length, 11000);
      if (!okAll) return;
      // 4) cierre
      try { window.MCNet.updateMission(missionId, { status: "done" }); } catch (e) {}
      setPackRun(null);
      jarvis("🎁 Su pack está completo, señor — " + plan.length + " piezas del mismo mensaje, coordinadas y listas en su estudio.");
      await delay(250);
      setStage("ready");
      jarvis("¿Le preparo algo más?", { suggestions: true });
    }

    // RETOMAR un pack a medias (Fase B): genera SOLO lo que falta, con el MISMO ángulo guardado.
    async function reanudarPack(mission) {
      if (thinking || packRun || resumeDone) return;
      const info = parsePack(mission); if (!info) return;
      const mid = mission.id;
      setResumeDone(true);
      pushMsg({ from: "u", text: "✅ Sí, termínelo", ui: true });
      setStage("pack_running");
      setPackRun(mid);
      setThinking({ label: "Revisando su pack, señor…" });
      const res = await doneSkillsDeMision(mid);   // AUTORITATIVO: qué falta de verdad (no el estudio truncado)
      setThinking(null);
      if (!res.ok) { jarvis("No pude leer el estado de su pack, señor — probemos en un momento."); setPackRun(null); setStage("ready"); jarvis("¿Le preparo algo más?", { suggestions: true }); return; }
      const doneSkills = res.done;
      const yaHechas = info.plan.filter(function (s) { return doneSkills[s]; }).length;
      const restantes = info.plan.filter(function (s) { return !doneSkills[s]; });
      if (!restantes.length) {   // ya estaba completo → solo cerrar
        try { window.MCNet.updateMission(mid, { status: "done" }); } catch (e) {}
        setMissions(function (ms) { return ms.map(function (m) { return m.id === mid ? Object.assign({}, m, { status: "done" }) : m; }); });
        setPackRun(null); setStage("ready"); jarvis("Su pack ya estaba completo, señor. Todo en su estudio.", { suggestions: true }); return;
      }
      // recuperar el ángulo guardado; si murió antes de guardarse (null) → regenerarlo
      let ang = info.angulo, gap = 0;
      if (!ang) {
        setThinking({ label: "JARVIS está recuperando el ángulo de su pack…" });
        const angRaw = await generarAngulo(info.idea);
        setThinking(null);
        if (!angRaw) { jarvis("La cuota está ocupada, señor — retomamos su pack en un minuto."); setPackRun(null); setStage("ready"); jarvis("¿Le preparo algo más mientras?", { suggestions: true }); return; }
        ang = compactAngle(angRaw) || angRaw;
        let objDone = JSON.stringify({ v: "pack1", idea: str(info.idea).slice(0, 200), plan: info.plan, angulo: ang });
        if (objDone.length > 2000) objDone = JSON.stringify({ v: "pack1", idea: str(info.idea).slice(0, 200), plan: info.plan, angulo: null });
        try { window.MCNet.updateMission(mid, { objective: objDone }); } catch (e) {}
        setMissions(function (ms) { return ms.map(function (m) { return m.id === mid ? Object.assign({}, m, { objective: objDone }) : m; }); });
        gap = 11000;
      }
      const angText = angleToText(ang);
      jarvis("Retomo su pack, señor — voy por " + (restantes.length === 1 ? "la que falta" : "las " + restantes.length + " que faltan") + ". 👉");
      const okAll = await correrPiezas(mid, info.idea, angText, restantes, yaHechas, info.plan.length, gap);
      if (!okAll) return;
      try { window.MCNet.updateMission(mid, { status: "done" }); } catch (e) {}
      setMissions(function (ms) { return ms.map(function (m) { return m.id === mid ? Object.assign({}, m, { status: "done" }) : m; }); });
      setPackRun(null);
      jarvis("🎁 Listo, señor — su pack quedó completo en su estudio.");
      await delay(250); setStage("ready"); jarvis("¿Le preparo algo más?", { suggestions: true });
    }

    async function descartarResume(mission) {
      if (packRun || resumeDone) return;
      setResumeDone(true);
      pushMsg({ from: "u", text: "Ya no", ui: true });
      try { window.MCNet.updateMission(mission.id, { status: "paused" }); } catch (e) {}
      setMissions(function (ms) { return ms.map(function (m) { return m.id === mission.id ? Object.assign({}, m, { status: "paused" }) : m; }); });
      setStage("ready");
      jarvis("Entendido, señor. Lo dejé en pausa. ¿Qué le preparo hoy?", { suggestions: true });
    }

    // APROBAR de un toque todas las piezas pendientes de un pack (sin IA, sin cuota).
    async function aprobarPack(mid) {
      if (packRun === mid || approvingPack) return;   // no mientras corre ni con otro aprobar en curso
      const piezas = archive.filter(function (d) { return d.mission_id === mid && d.status !== "approved"; });
      if (!piezas.length) return;
      setApprovingPack(mid);
      for (let i = 0; i < piezas.length; i++) {
        const id = piezas[i].id;
        try { const r = await window.MCNet.approveDraft(id); if (r && r.ok) setArchive(function (a) { return a.map(function (x) { return x.id === id ? Object.assign({}, x, { status: "approved" }) : x; }); }); } catch (e) {}
      }
      setApprovingPack(null);
      jarvis("✓ Aprobé todo el pack, señor — quedó firmado en su estudio.");
    }

    // "Empezar de cero" (reinicia la marca para un nicho nuevo). Puente al multi-nicho que viene.
    function pedirReset() {
      if (busy || packRun) return;
      pushMsg({ from: "j", text: "¿Seguro que quiere empezar de cero, señor? Borraré su marca y esta conversación para arrancar un nicho nuevo. (Sus piezas del estudio se quedan.)", resetConfirm: true });
    }
    async function empezarDeCero() {
      setThinking({ label: "Reiniciando, señor…" });
      try { await window.MCNet.resetPerfil(); } catch (e) {}
      try { localStorage.removeItem(chatKey(nicho)); } catch (e) {}
      setThinking(null);
      setPerfil({});
      setChosen(null);
      setMsgs([]);
      setStage("discovery");
      jarvis(PREGUNTAS[0].q);
    }

    // Traduce el motivo técnico al idioma del mayordomo. NUNCA se le muestra al usuario
    // un código crudo ('invalid_ai_payload', 'db', 'network'): no le dice qué hacer ni si
    // fue culpa suya, y rompe el tono del resto del chat.
    // `que` = qué estaba haciendo ("prepararlo", "ajustarlo"…) para que el aviso encaje.
    function textoError(reason, que, prov) {
      const q = que || "prepararlo";
      switch (reason) {
        // — el usuario puede hacer algo —
        // Mandaba a «JARVIS → ⚙ Llave IA», que está FUERA del Centro de Mando: le obligaba a
        // salir de donde estaba trabajando para volver a entrar. Las llaves se pegan en ALFRED,
        // que es una pestaña de aquí mismo. Ver `avisaLlavesQueFaltan`, que lo dice ANTES.
        case "missing_user_key":  return "Me falta su llave para trabajar, señor. Péguela en la pestaña ALFRED, aquí arriba, en «Sus llaves» — y seguimos donde lo dejamos.";
        case "pace":              return "Un momento, señor — sigo con lo anterior. Inténtelo en unos segundos.";
        case "rate_limit":        return "Su llave necesita un respiro, señor: alcanzó su límite por minuto. Esperemos unos segundos y lo reintento.";
        // ⚠️ EL TOPE DEL DÍA NO SE ABRE ESPERANDO, y decirle que espere le costó cinco minutos a
        //    Danny reintentando en vano. Aquí la acción es la CONTRARIA que en el de por minuto:
        //    cambiar de llave o dejarlo para mañana. Se separan por eso (ver _mcClasifica429).
        case "cuota_diaria":      return "Su llave agotó el cupo del DÍA, señor — no es cosa de esperar unos segundos. Conecte otra llave en JARVIS → ⚙ Llave IA y seguimos ahora mismo, o lo retomamos mañana.";
        // ⚠️ AÑADIDO EL 28-jul, Y ERA UN FALLO QUE HACÍA PERDER EL TIEMPO. La llave de Danny
        //    había dejado de ser válida (HTTP 401) y JARVIS le decía «alcanzó su límite por
        //    minuto, esperemos unos segundos»: le mandó a esperar DIEZ MINUTOS por algo que
        //    no se iba a arreglar solo nunca. Un 401 y un 429 son cosas distintas y el
        //    usuario tiene que saber cuál le toca — en una se espera, en la otra se cambia
        //    la llave. Se comprobó llamando a Mistral con las tres llaves suyas: dos
        //    respondían y la que tenía puesta daba 401.
        // ⚠️ El señor lleva DOS llaves puestas (una conversa, otra escribe las piezas). Decirle
        //   «su llave de IA» a secas le deja adivinando cuál, y mandarle a «⚙ Puesta a punto»
        //   —que es de la interfaz— cuando la que falla es la de Mistral, que se pega en ALFRED.
        //   El servidor ya manda `proveedor`; aquí no se leía. Se usa cuando viene.
        case "llave_mala":        return "Su llave" + (prov ? " de " + prov : " de IA") + " ya no es válida, señor — el proveedor la rechaza. No es cuestión de esperar: hay que poner una nueva en la pestaña ALFRED, en «Sus llaves». Quizá la regeneró o caducó.";
        case "cuota_mensual":     return "Ha agotado la cuota mensual de su llave" + (prov ? " de " + prov : "") + ", señor. No se repone hasta el mes que viene — y prefiero decírselo a seguir tirando de la otra llave, que es la que le escribe las piezas.";
        case "no-idea":           return "Necesito la idea, señor — dígame en una frase de qué quiere que trate.";
        // ⚠️ NO afirmar la causa. El server manda 'no-account' tanto si no llegó el token
        // como si el token ya no da Pro (reembolso/disputa — raro, porque el Pro es DE POR
        // VIDA, pero el webhook de revocación existe). Tampoco nombrar "suscripción":
        // confunde a quien pagó UNA vez. Frase que sirve a los dos casos y da el paso útil.
        case "no-account":        return "No pude confirmar su acceso, señor. Vuelva a entrar a JARVIS y lo reintentamos.";
        // — cosas nuestras, no suyas —
        case "timeout":           return "Tardó más de la cuenta y lo corté, señor. ¿Lo intento otra vez?";
        case "network":           return "Se cortó la conexión a mitad de camino, señor. ¿Lo reintento?";
        case "ai_failed":
        case "ai_error":
        case "ai_empty":
        case "ai_badjson":
        case "invalid_ai_payload": return "No me llegó bien la respuesta y preferí no entregarle algo a medias, señor. ¿Lo reintento?";
        case "db":                return "Lo preparé pero no pude guardarlo en su estudio, señor. Reintentemos para no perderlo.";
        case "no-config":         return "El estudio no está disponible en este momento, señor. Vuelva a intentarlo en un rato.";
        // — estado / permisos —
        case "canary_only":       return "Ese formato todavía está en construcción, señor.";
        case "unknown_skill":     return "Ese formato no existe todavía, señor.";
        case "not-found":         return "No encuentro esa pieza en su estudio, señor. ¿Recargamos la página?";
        case "not-pending":       return "Esa pieza ya está firmada, señor — las firmadas no se ajustan. ¿Le preparo una nueva?";
        case "classify_failed":   return "No le entendí bien, señor. ¿Me lo dice de otra forma?";
        case "bad-input":         return "Me falta el detalle del cambio, señor. Dígame qué le ajusto.";
        default:                  return "No pude " + q + ", señor. Probemos de nuevo.";
      }
    }
    // El campo "proveedor" lo manda el servidor para poder decirle CUÁL de sus dos llaves falla.
    function manejarError(r, que) { jarvis(textoError(r && r.reason, que, r && r.proveedor)); }
    // ⭐⭐⭐ LA CIFRA QUE NO ES SUYA. JARVIS lo detecta con certeza y antes se quedaba en un
    //   registro que no mira nadie: la pieza salía con el número inventado intacto y el señor la
    //   publicaba con su cara, creyendo que era suyo. Se le DICE, no se le borra: el daño no es
    //   la cifra, es publicarla CREYENDO QUE ES SUYA. Sabiéndolo decide él —que es quien pone la
    //   cara— y de paso aprende a desconfiar de los números, que es justo su método.
    //   Misma doctrina que el aviso de parecido: avisa, no bloquea.
    //   ⚠️ Vive AQUÍ, en UNA sola función, porque hace falta en TRES sitios: al generar, al pedir
    //      un cambio y en cada pieza del pack. Escrita tres veces se desincroniza — hoy ya nos ha
    //      pasado dos veces con reglas copiadas a mano.
    // ⭐⭐ POR QUÉ SE CORTÓ EL PACK — la verdad, no «la cuota» siempre. Es la diferencia entre
    //   «espere un rato» (cierto solo con la cuota) y «vaya a cambiar su llave» (lo único que
    //   sirve si murió). Con el texto de antes, el señor se iba a esperar HORAS por algo que la
    //   espera no arreglaba, y al volver le decían exactamente lo mismo.
    function motivoPackCortado(hechas, total) {
      const r = packFalloRef.current; packFalloRef.current = null;
      const hechoYa = "Generé " + hechas + " de " + total + ", señor. ";
      const reason = r && r.reason;
      const prov = (r && r.proveedor) ? " de " + r.proveedor : "";
      if (reason === "llave_mala")
        return hechoYa + "Su llave" + prov + " ha dejado de ser válida — el proveedor la rechaza, así que esperar no servirá de nada. Póngala de nuevo en ALFRED → «Sus llaves» y termino el resto en cuanto me avise.";
      if (reason === "cuota_diaria")
        return hechoYa + "Ha agotado la cuota de HOY de su llave" + prov + ". Se repone mañana; el resto queda pendiente y se lo recuerdo al volver.";
      if (reason === "cuota_mensual")
        return hechoYa + "Ha agotado la cuota mensual de su llave" + prov + ". No se repone hasta el mes que viene — el resto queda pendiente, y le espero.";
      if (reason === "missing_user_key" || reason === "no-key")
        return hechoYa + "Me falta una llave para seguir. Conéctela en ALFRED → «Sus llaves» y termino el resto.";
      if (reason === "no-account")
        return hechoYa + "He perdido su sesión. Vuelva a entrar en su JARVIS y retomo el resto donde lo dejé.";
      if (reason === "rate_limit" || reason === "pace")
        return hechoYa + "La cuota de su llave pide un respiro — el resto queda pendiente y se lo recuerdo al volver.";
      // Lo que no sabemos nombrar NO se disfraza de cuota: se dice que no se sabe.
      return hechoYa + "Algo me ha impedido seguir y no sabría decirle qué con certeza. El resto queda pendiente; inténtelo de nuevo en un rato y, si vuelve a pasar, avíseme.";
    }
    async function avisaCifras(r) {
      const cs = r && r.cifrasSinOrigen;
      if (!cs || !cs.length) return;
      await delay(250);
      const n = cs.length;
      jarvis("⚠️ Y algo que debo decirle, señor: en esta pieza hay " + (n === 1 ? "una cifra" : n + " cifras")
        + " que no me consta" + (n === 1 ? "" : "n") + " que " + (n === 1 ? "sea suya" : "sean suyas") + " — "
        + cs.map((c) => "«" + c + "»").join(", ") + ". No " + (n === 1 ? "está" : "están")
        + " en su marca, ni en lo que me pidió, ni en su ficha. Yo no invento datos a propósito, "
        + "pero a veces se me cuelan. Confírmeme" + (n === 1 ? "la" : "las") + " si " + (n === 1 ? "es" : "son")
        + " de verdad y se " + (n === 1 ? "la" : "las") + " guardo en su ficha; si no, dígame «quita "
        + (n === 1 ? "esa cifra" : "esas cifras") + "» y se " + (n === 1 ? "la" : "las") + " reescribo. "
        + "No la publique con " + (n === 1 ? "ella" : "ellas") + " sin estar seguro: la cara la pone usted.");
    }

    // ----- acciones del estudio -----
    async function pedirCambio(d) {
      const fb = refineText.trim(); if (fb.length < 3) return;
      setRefiningId(null); setRefineText(""); setPieceBusy(d.id);
      const r = await conReintentoPace(function () { return window.MCNet.refine(d.id, fb); });   // pieceBusy cubre la espera del reintento
      setPieceBusy(null);
      if (r && r.ok && r.deliverable) {
        setArchive((a) => a.map((x) => x.id === d.id ? r.deliverable : x));
        jarvis("Hecho, señor. Ajusté su " + (KIND_LABEL[d.kind] || "pieza").replace(/^\S+\s/, "").toLowerCase() + " en el estudio.");
        // Pedir un cambio es justo cuando el modelo rellena huecos para complacer: el propio
        // servidor decía que «el refine es donde más se empuja a inventar», y era el único
        // sitio donde el hallazgo se quedaba en el registro sin llegarle a él.
        await avisaCifras(r);
      } else { manejarError(r, "ajustarlo"); }
    }
    async function aprobar(d) {
      setPieceBusy(d.id);
      try { const r = await window.MCNet.approveDraft(d.id); setPieceBusy(null);
        if (r && r.ok) { setArchive((a) => a.map((x) => x.id === d.id ? Object.assign({}, x, { status: "approved" }) : x)); jarvis("✓ Aprobado, señor. Quedó firmado en su estudio.", { sys: true }); }   // la pieza ya muestra "✓ Firmado": la burbuja no hace falta mañana
        else jarvis("No se pudo aprobar, señor."); }
      catch (e) { setPieceBusy(null); jarvis("No se pudo aprobar, señor."); }
    }
    function ocultar(d) { try { window.MCNet.archiveDraft(d.id); } catch (e) {} setArchive((a) => a.filter((x) => x.id !== d.id)); if (openId === d.id) setOpenId(null); }

    const busy = !!thinking;
    const Carr = window.MCCarruselImg;

    // ---------- CHAT LIBRE (Fase 3): conversar + ejecutar la acción ----------
    async function conversar(text) {
      const q = (text || "").trim();
      if (!q || busy || packRun) return;
      pushMsg({ from: "u", text: q });
      setInput("");
      // historial = bubbles de texto PREVIOS (el mensaje actual va aparte en `message`)
      // sin los avisos de estado: no son conversación y gastaban huecos de los 16 turnos
      const history = msgs.filter(function (m) { return m && m.text && !m.sys && (m.from === "u" || m.from === "j"); })
                          .slice(-16).map(function (m) { return { role: m.from === "u" ? "user" : "jarvis", content: m.text }; });
      setThinking({ label: "JARVIS está pensando…" });
      const r = await conReintentoPace(
        function () { return window.MCNet.chat({ message: q, history: history }); },
        "JARVIS sigue con lo suyo, señor…"
      );
      setThinking(null);
      if (!(r && r.ok)) {
        // Antes, CUALQUIER fallo decía "no le entendí bien" — hasta cuando el problema
        // era la base de datos o un tiempo agotado. Ahora cada motivo dice lo suyo.
        manejarError(r, "responderle");
        return;
      }
      jarvis(r.reply || "…");
      await ejecutarAccion(r.action, q);
    }

    // ----- "Lo que sé de usted" (ver / olvidar los hechos) -----
    async function abrirSaber() {
      const abrir = !saberOpen;
      setSaberOpen(abrir);
      if (!abrir) return;
      try {
        const r = await window.MCNet.getHechos();
        setHechos(r && r.ok && Array.isArray(r.hechos) ? r.hechos : []);
      } catch (e) { setHechos([]); }
    }
    async function olvidarHecho(id) {
      setOlvidando(id);
      try {
        const r = await window.MCNet.delHecho(id);
        if (r && r.ok && Array.isArray(r.hechos)) setHechos(r.hechos);
      } catch (e) {}
      setOlvidando(null);
    }

    async function ejecutarAccion(action, idea) {
      if (!action) return;
      // ⭐ EL HECHO va PRIMERO y es independiente del tipo: si le contó algo que vale
      // recordar, se guarda AUNQUE además esté pidiendo una pieza. Fire-and-forget:
      // que falle el guardado NUNCA debe frenar lo que el usuario pidió.
      if (action.hecho) {
        try {
          // ⭐⭐ SI JARVIS ACABA DE PEDIRLE SU HISTORIA, LO QUE CUENTE AHORA *ES* SU HISTORIA.
          //   El servidor no deja escribir una pieza sobre «su historia» hasta que exista un
          //   hecho MARCADO como tal, y el único sitio que ponía esa marca era el cuestionario
          //   de la ficha. En el estudio no se ponía nunca: el señor pedía «un carrusel sobre
          //   cómo empecé», JARVIS le preguntaba qué pasó, él se lo contaba... y se guardaba
          //   como un hecho cualquiera. Así que volvía a pedírsela. Y otra vez. Para siempre,
          //   por más veces que se la contara. Un bucle sin salida en la función más personal.
          const _eraSuHistoria = !!faltaHistRef.current;
          faltaHistRef.current = null;
          const p = window.MCNet.addHecho(action.hecho, { esHistoria: _eraSuHistoria });
          if (p && typeof p.then === "function") {
            p.then(function (r) {
              // si el panel está abierto, que lo vea aparecer en vivo
              if (r && r.ok && Array.isArray(r.hechos)) setHechos(r.hechos);
            }, function () {});   // el catch es obligatorio: sin él, un fallo de red deja una promesa colgada
          }
        } catch (e) {}
      }
      if (action.tipo === "none") return;
      if (action.tipo === "ideas") {
        setChosen("reel_script");   // las ideas que toque generarán un guion (default sensato; NO depender de `chosen` viejo)
        setThinking({ label: "JARVIS está preparando ideas, señor…" });
        await delay(10000);   // el chat consumió el slot de cuota → aire antes de generar (igual que 'crear')
        setThinking(null);
        await demeIdeas(true);
        return;
      }
      if (action.tipo === "perfil" && action.perfil) {
        try { window.MCNet.setPerfil(action.perfil); } catch (e) {}
        setPerfil(function (p) { return Object.assign({}, p || {}, action.perfil); });
        return;
      }
      if (action.tipo === "crear" && action.skill && action.idea) {
        if (action.skill === "reel_script") {   // guion → primero elige la DURACIÓN (la idea ya vino)
          setChosen("reel_script");
          setPendingGuionIdea(action.idea);
          jarvis("Con gusto, señor. ¿De cuánto lo quiere?", { durChips: true });
          return;
        }
        const agent = AGENT_BY_SKILL[action.skill] || "Atlas";
        setThinking({ label: agent + " se pone con ello, señor…" });
        await delay(10000);   // el chat consumió el slot de cuota → dar aire antes de generar
        setThinking(null);
        await generarCon(action.idea, action.skill);
        return;
      }
      if (action.tipo === "pack" && action.idea) {
        pushMsg({ from: "j", text: "Un pack son varias piezas coordinadas (guion, carrusel, texto y títulos), señor. ¿Se lo armo completo?", packConfirm: action.idea });
        return;
      }
    }

    // ----- filtrado + secciones del estudio (buscador + tipo + pendientes/firmadas) -----
    const _q = (query || "").trim().toLowerCase();
    // ⚠️ Se busca por el rótulo NUEVO y por el crudo: el crudo son SUS palabras, y es como la
    //    gente recuerda lo que pidió («la de la torta») aunque la ficha se llame de otro modo.
    const _match = function (d) { return !_q || String(d.title || "").toLowerCase().indexOf(_q) >= 0 || String(tituloDe(d)).toLowerCase().indexOf(_q) >= 0 || String(d.content_text || "").toLowerCase().indexOf(_q) >= 0; };
    const _filtered = archive.filter(function (d) { return (!filterType || d.kind === filterType) && _match(d); });
    const pendientes = _filtered.filter(function (d) { return d.status !== "approved"; });
    const firmadas = _filtered.filter(function (d) { return d.status === "approved"; });
    const tiposPresentes = []; const _seenK = {};
    archive.forEach(function (d) { if (d.kind && !_seenK[d.kind] && KIND_META[d.kind]) { _seenK[d.kind] = 1; tiposPresentes.push(Object.assign({ kind: d.kind }, KIND_META[d.kind])); } });

    // ----- agrupación de PACKS (misiones {v:'pack1'}) -----
    const packById = {};
    missions.forEach(function (m) { const info = parsePack(m); if (info) packById[m.id] = { mission: m, info: info }; });
    const isPackPiece = function (d) { return !!(d && d.mission_id && packById[d.mission_id]); };
    const pendSueltas = pendientes.filter(function (d) { return !isPackPiece(d); });
    const pendPacks = {};   // mission_id -> [piezas pendientes]
    const packOrder = [];   // orden de aparición de los packs en Pendientes
    pendientes.forEach(function (d) { if (isPackPiece(d)) { if (!pendPacks[d.mission_id]) { pendPacks[d.mission_id] = []; packOrder.push(d.mission_id); } pendPacks[d.mission_id].push(d); } });
    // el pack EN CURSO aparece aunque aún no tenga piezas (muestra sus filas fantasma)
    if (packRun && packById[packRun] && packOrder.indexOf(packRun) < 0) packOrder.unshift(packRun);
    const showPack = !!(skillIds && skillIds.indexOf("pack_angle") >= 0);

    // ----- tarjeta del estudio -----
    var CAR_STYLES = (window.MCCarruselStyles || [{ id: "perfil1", name: "Editorial" }, { id: "perfil2", name: "Etiqueta" }, { id: "perfil3", name: "Titular" }, { id: "perfil4", name: "Bloques" }, { id: "perfil5", name: "Lista" }, { id: "perfil6", name: "Split" }, { id: "perfil7", name: "Cita" }]);
    var CAR_FONTS = (window.MCCarruselFonts || [{ key: "impacto", name: "Impacto", tf: "'Big Shoulders Display', Arial, sans-serif" }]);
    // Barra de ESTILO en vivo del carrusel (Estudio): cambia perfil / color / @ y re-pinta al
    // instante SIN regenerar la imagen (solo la capa de texto). Sello de marca GLOBAL (localStorage).
    function carStyleBar() {
      return (
        <div className="carfx">
          {/* Danny, probando en canary: "¿dónde lo guardo para que me genere todo así,
              sin tener que modificar cada carrusel?". Ya se guardaba solo — pero la única
              pista era un "se aplica al instante" en gris. La función existía y NO se
              comunicaba: si el dueño no sabe que quedó guardado, nadie lo va a saber. */}
          <div className="carfx-sello">🎨 EL SELLO DE {(nichoNombre || "su marca").toUpperCase()} · se guarda solo</div>
          <div className="carfx-row">
            <span className="carfx-lbl">🎨 Estilo</span>
            <div className="carfx-chips">
              {CAR_STYLES.map(function (t) {
                return <button key={t.id} type="button" className={"carfx-chip" + (carTpl === t.id ? " on" : "")} onClick={function () { setCarTpl(t.id); }}>{t.name}</button>;
              })}
            </div>
          </div>
          <div className="carfx-row">
            <span className="carfx-lbl">✍️ Fuente</span>
            <div className="carfx-chips">
              {CAR_FONTS.map(function (f) {
                return <button key={f.key} type="button" className={"carfx-chip" + (carFont === f.key ? " on" : "")} style={{ fontFamily: f.tf }} onClick={function () { setCarFont(f.key); }}>{f.name}</button>;
              })}
            </div>
          </div>
          <div className="carfx-row">
            <label className="carfx-field"><span className="carfx-lbl">Color</span>
              <input type="color" className="carfx-color" value={/^#[0-9a-fA-F]{6}$/.test(carColor) ? carColor : "#00e0a4"} onChange={function (e) { setCarColor(e.target.value); }} /></label>
            <label className="carfx-field"><span className="carfx-lbl">Su @</span>
              <input type="text" className="carfx-handle" placeholder="@sumarca" value={carHandle} onChange={function (e) { setCarHandle(e.target.value); }} /></label>
          </div>
          <div className="carfx-hint">
            {selloGuardado
              ? <b style={{ color: "#7ce8c0" }}>✓ Guardado. Todos sus carruseles saldrán así, señor — no hay que repetirlo.</b>
              : <span>Elija su combinación una vez: <b>queda guardada</b> y todos sus carruseles saldrán así. No hay que tocarla en cada pieza.</span>}
          </div>
        </div>
      );
    }
    function renderPieza(d) {
      const open = openId === d.id;
      const isCarr = d.kind === "carrusel_img";
      const approved = d.status === "approved";
      const pbusy = pieceBusy === d.id;
      // 🐛 BUG CAZADO EN CANARY (captura de Danny): antes decía `approvingPack === d.mission_id`
      // a secas. Una pieza SUELTA tiene mission_id = null, y approvingPack arranca en null →
      // null === null = TRUE → toda pieza fuera de un pack nacía con Aprobar / Pedir cambio /
      // Ocultar DESHABILITADOS PARA SIEMPRE. Imposible firmarlas. El `!= null` es el arreglo.
      const packLocked = !!(packRun && d.mission_id === packRun) || (approvingPack != null && approvingPack === d.mission_id);
      return (
        <div className={"mcx-arch-card" + (approved ? " ok" : "")} key={d.id}>
          <div className="mcx-arch-top">
            <div className="mcx-arch-info">
              <div className="mcx-arch-kind">{(isPackPiece(d) ? "🎁 " : "") + (KIND_LABEL[d.kind] || d.kind)}{d.revision_count ? " · ajustado " + d.revision_count + "x" : ""}</div>
              <div className="mcx-arch-title">{tituloDe(d)}</div>
              {KIND_WHERE[d.kind] ? <div className="mcx-where">📍 {KIND_WHERE[d.kind]}</div> : null}
              {(function () { const pv = open ? "" : archPreview(d, tituloDe(d)); return pv ? <div className="mcx-arch-prev">{pv}</div> : null; })()}
            </div>
            {approved ? <span className="mcx-arch-okpill">✓ Firmado</span> : null}
          </div>

          {open ? (isCarr
            ? (Carr ? <div style={{ marginTop: 10 }}>{carStyleOn ? carStyleBar() : null}<Carr slides={(d.content && d.content.slides) || []} style={d.content && d.content.style} accent={d.content && d.content.accent} pieceId={d.id} ver={d.revision_count || 0} template={carTpl} brandColor={carColorApplied} handle={carHandleApplied} font={carFont} /></div>
                    : <div className="mcx-full">Las imágenes del carrusel se están cargando…</div>)
            : <div className="mcx-full">{d.content_text || ""}</div>) : null}

          {/* ⭐⭐ EL PIE — LO QUE DE VERDAD SE PUBLICA. Al abrir un carrusel solo se pintaban las
              LÁMINAS: el bloque que muestra el texto estaba en la rama del `else`, o sea solo
              para las piezas que NO son carrusel. Así que el señor firmaba «✓ Aprobar», pulsaba
              «Publicar en IG»... y salía en su Instagram, con su nombre, un texto de hasta 2.200
              caracteres QUE NUNCA ESTUVO EN PANTALLA. Publicar en su nombre algo que él no ha
              leído es lo más grave que puede hacer este producto. Ahora se le enseña antes. */}
          {open && isCarr ? (
            <div className="mcx-full" style={{ marginTop: 12, borderTop: "1px solid rgba(126,232,255,.14)", paddingTop: 10 }}>
              <div style={{ fontSize: 11, letterSpacing: ".06em", opacity: .7, marginBottom: 6 }}>
                ✍️ EL PIE DEL POST — esto es lo que se publicará debajo de las láminas
              </div>
              {(d.content && d.content.caption) || d.content_text
                ? <div style={{ whiteSpace: "pre-wrap" }}>{(d.content && d.content.caption) || d.content_text}</div>
                : <div style={{ opacity: .75 }}>Sin pie, señor. Pídame un cambio y se lo escribo antes de publicar.</div>}
            </div>
          ) : null}

          {pbusy ? (
            <div className="mcx-think" style={{ marginTop: 10 }}><span className="mcx-td"></span><span className="mcx-td"></span><span className="mcx-td"></span>Atlas está ajustando su pieza, señor…</div>
          ) : refiningId === d.id ? (
            <div className="mcx-inrow">
              <textarea className="mcx-input" value={refineText} placeholder="¿Qué cambio, señor? Ej: más corto, otro título…"
                onChange={(e) => setRefineText(e.target.value)}
                onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); pedirCambio(d); } }} />
              <button className="mcx-send" disabled={pbusy} onClick={() => pedirCambio(d)}>{pbusy ? "…" : "Enviar"}</button>
            </div>
          ) : (
            <div>
              <div className="mcx-acts">
                {/* Decía "Ocultar" al estar abierta — y el botón del final TAMBIÉN dice "Ocultar",
                    pero ese ARCHIVA la pieza para siempre. Dos botones con el mismo nombre y
                    efectos opuestos, uno al lado del otro: un clic de más y perdía el trabajo. */}
                <button className="mcx-btn" onClick={() => setOpenId(open ? null : d.id)}>{open ? "Cerrar" : "Ver"}</button>
                {!approved ? <button className="mcx-btn go" disabled={pbusy || packLocked} onClick={() => aprobar(d)}>✓ Aprobar</button> : null}
                {!approved ? <button className="mcx-btn" disabled={pbusy || packLocked} onClick={() => { setRefiningId(d.id); setRefineText(""); }}>✏️ Pedir cambio</button> : null}
                {/* ⭐⭐ LAS FLECHAS (31-jul, idea de Danny). Antes un cambio era IRREVERSIBLE para
                    el usuario: el historial se guardaba desde hacía tiempo en mc_revisions y
                    nadie lo leía. Él lo dijo probando: «muchas veces piensa que solo puede
                    decírselo a JARVIS… debería haber una flecha para volver al cambio anterior».
                    Y tenía razón en algo más: cada prueba suya era destructiva.
                    Solo salen si la pieza SE HA TOCADO alguna vez (revision_count > 0) — antes
                    de eso no hay a dónde ir y dos flechas muertas solo confunden. */}
                {!approved && (d.revision_count || 0) > 0 ? <button className="mcx-btn" disabled={pbusy || packLocked || verBusy === d.id || (verInfo[d.id] ? verInfo[d.id].index <= 0 : false)}
                  title={verInfo[d.id] && verInfo[d.id].index <= 0 ? "Ya está en la primera versión" : "Volver a la versión anterior"} onClick={() => versionar(d, -1)}>◀</button> : null}
                {!approved && (d.revision_count || 0) > 0 ? <button className="mcx-btn" disabled={pbusy || packLocked || verBusy === d.id || (verInfo[d.id] ? verInfo[d.id].index >= verInfo[d.id].total - 1 : false)}
                  title={verInfo[d.id] && verInfo[d.id].index >= verInfo[d.id].total - 1 ? "Ya está en la última versión" : "Ir a la versión siguiente"} onClick={() => versionar(d, +1)}>▶</button> : null}
                <button className="mcx-btn" onClick={() => copiar(d.content_text || "")}>📋 Copiar</button>
                {approved && isCarr && igPublishOn && igConn && igConn.connected ? <button className="mcx-btn go" disabled={!!igPubBusy} onClick={() => setIgPubConfirm(igPubConfirm === d.id ? null : d.id)}>{igPubBusy === d.id ? "Publicando…" : "📸 Publicar en IG"}</button> : null}
                {/* Es PERMANENTE (archive) — "Ocultar" lo hacía sonar reversible. */}
                <button className="mcx-btn mut" disabled={packLocked} title="La saca del estudio (no se borra, pero deja de aparecer)" onClick={() => ocultar(d)}>✕ Quitar del estudio</button>
              </div>
              {/* ⭐ EL AVISO DE LOS NÚMEROS (28-jul). Medido sobre 210 piezas del código de hoy:
                  el 18% publica un número que el dueño nunca dio. El candado del servidor ya
                  caza y repara los que puede, pero no todos — y lo que se cuela lo publica ÉL
                  con su nombre. Hoy la pantalla no decía nada, así que el usuario aprobaba a
                  ciegas. Esto no arregla el 18%: convierte un riesgo CIEGO en uno AVISADO, que
                  es otra cosa. Va solo en el carrusel y solo antes de aprobar — después ya no
                  molesta. Sin alarmismo y sin jerga: quien conoce sus precios es él. */}
              {isCarr && !approved ? (
                <div className="mcx-ojo">Un repaso a los números antes de aprobar — precios, plazos y medidas. Usted es quien los conoce.</div>
              ) : null}
              {igPubConfirm === d.id ? (
                <div className="mcx-inrow" style={{ marginTop: 8, alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                  <span style={{ fontSize: 12.5, opacity: .92 }}>Se publicará este carrusel en <b>@{(igConn && igConn.username) || "instagram"}</b> — es <b>real y público</b>.</span>
                  <button className="mcx-btn go" disabled={!!igPubBusy} onClick={() => publicarIG(d, false)}>Sí, publicar</button>
                  {/* 🧪 EL ENSAYO ES HERRAMIENTA DE DANNY, NO FUNCIÓN DEL PRODUCTO — solo canary.
                      Lo vio él al ir a publicar (03-ago): *«a la hora de publicar a los usuarios
                      esto no debería salir, eso sería fatal»*. Y tiene razón: un Pro que le da a
                      «ensayo» creyendo que publica, y no publica nada, se lleva la peor sorpresa
                      posible — creer que su post salió cuando no salió.
                      Se ESCONDE, no se borra: hace falta en cada prueba futura del publicado. */}
                  {canaryOn ? <button className="mcx-btn" disabled={!!igPubBusy} title="Prueba TODO el flujo sin publicar nada (no toca tu perfil)" onClick={() => publicarIG(d, true)}>🧪 Probar (ensayo)</button> : null}
                  <button className="mcx-btn mut" disabled={!!igPubBusy} onClick={() => setIgPubConfirm(null)}>Cancelar</button>
                </div>
              ) : null}
            </div>
          )}
        </div>
      );
    }

    // ----- grupo de un PACK en Pendientes (cabecera + piezas + filas fantasma) -----
    function renderPackGroup(mid) {
      const pk = packById[mid]; if (!pk) return null;
      const info = pk.info;
      const piezas = pendPacks[mid] || [];
      const hechas = archive.filter(function (d) { return d.mission_id === mid; }).length;
      const total = info.plan.length;
      const running = packRun === mid;
      const doneSkills = {};
      archive.forEach(function (d) { if (d.mission_id === mid && d.gen_meta && d.gen_meta.skill) doneSkills[d.gen_meta.skill] = 1; });
      const ghosts = running ? info.plan.filter(function (s) { return !doneSkills[s]; }) : [];
      const gancho = info.angulo && str(info.angulo.gancho);
      const packBusy = approvingPack === mid;
      const packPend = archive.filter(function (d) { return d.mission_id === mid && d.status !== "approved"; });   // pendientes REALES (sin filtro) = lo que aprobará el botón
      return (
        <div className="mcx-pack" key={"pack-" + mid}>
          <div className="mcx-pack-head">
            {/* Mismo caso que el rótulo de la ficha: `info.idea` es su frase cruda, con sus faltas.
                El ángulo YA trae la misma idea bien escrita (mensaje_central), y encima es más fiel
                a lo que el pack de verdad cuenta. Mientras se define el ángulo aún no existe → su frase. */}
            <div className="mcx-pack-title">🎁 Pack: {(info.angulo && str(info.angulo.mensaje_central)) || info.idea} · {hechas} de {total}</div>
            {gancho ? <div className="mcx-pack-sub">{gancho}</div> : (running ? <div className="mcx-pack-sub">Definiendo el ángulo…</div> : null)}
          </div>
          {!running && packPend.length ? (
            <button className="mcx-btn go" style={{ alignSelf: "flex-start" }} disabled={packBusy} onClick={function () { aprobarPack(mid); }}>{packBusy ? "Aprobando…" : "✓ Aprobar todo el pack (" + packPend.length + ")"}</button>
          ) : null}
          {piezas.map(renderPieza)}
          {ghosts.map(function (s) {
            return (
              <div className="mcx-ghost" key={"g-" + mid + "-" + s}>
                <div className="mcx-arch-kind">{KIND_LABEL[SKILL_KIND[s]] || s}</div>
                <div className="mcx-think" style={{ marginTop: 6 }}><span className="mcx-td"></span><span className="mcx-td"></span><span className="mcx-td"></span>{(AGENT_BY_SKILL[s] || "Atlas") + " preparará " + suSkill(s) + " " + (SKILL_SHORT[s] || "pieza") + "…"}</div>
              </div>
            );
          })}
        </div>
      );
    }

    return (
      <div className="mcx-studio">
        {/* ---------- IZQUIERDA: el chat ---------- */}
        <div className="mcx-left">
          <div className="mcx-head">
            <span className="mcx-dot"></span>
            <span className="mcx-title">JARVIS · Orquestador<small>Dígame qué necesita y coordino a su equipo, señor.</small></span>
            <span className="mcx-badge">NUEVO</span>
            {stage === "ready" ? <button className="mcx-reset" onClick={abrirSaber}>🧠 Lo que sé de usted</button> : null}
            {stage === "ready" ? <button className="mcx-reset" disabled={busy || !!packRun} onClick={pedirReset}>↺ Empezar de cero</button> : null}
          </div>

          {saberOpen ? (
            <div className="mcx-saber">
              <h4>🧠 Lo que sé de usted</h4>
              <p className="sub">Esto es lo que he ido anotando de lo que me cuenta, señor. Lo tengo presente cada vez que le preparo algo. Si algo está de más o ya no aplica, dígamelo y lo olvido.</p>
              {/* ⭐ LA BARRA — idea de Danny (28-jul): «que se vea que está en 0% y va cargando
                  mientras más llene la información; mientras más porcentaje, JARVIS sabe más
                  de él». Y no es un adorno: está MEDIDO que llenar la ficha mejora las piezas
                  — con 8 datos el 42 % salen sin ningún fallo, con 17 el 58 %, y doblar la
                  ficha bajó «sin nada comprobable» del 50 % al 14,6 %. La barra es su nota.
                  ⚠️ Se cuentan FRENTES cubiertos, no datos sueltos: se podrían tener veinte
                  hechos y todos de precio. Y debajo se dice QUÉ FALTA — un número solo
                  motiva; un número que dice qué falta, mueve. */}
              {Array.isArray(hechos) ? (() => {
                // usa la MISMA cuenta que la ficha y que los avisos: una sola verdad.
                const s = cuantoSabe(hechos);
                return (
                  <div style={{ margin: "2px 0 11px" }}>
                    <div style={{ display: "flex", justifyContent: "space-between", fontSize: 11.5, color: "#9fd8ef", marginBottom: 4 }}>
                      <span>JARVIS le conoce al <b style={{ color: s.pct >= 75 ? "#7ee8ff" : "#ffd08a" }}>{s.pct}%</b></span>
                      <span style={{ opacity: .75 }}>{s.hechas.length} de {s.total}</span>
                    </div>
                    <div style={{ height: 6, borderRadius: 4, background: "rgba(126,232,255,.13)", overflow: "hidden" }}>
                      <div style={{ height: "100%", width: s.pct + "%", borderRadius: 4, transition: "width .5s ease",
                        background: "linear-gradient(90deg,#7ee8ff,#a0ffd8)" }} />
                    </div>
                    {s.faltan.length ? (
                      <p className="sub" style={{ margin: "6px 0 0" }}>
                        Me falta que me cuente{" "}
                        {s.faltan.slice(0, 2).map((f, k) => (
                          <span key={f.k}>{k ? " y " : ""}<b>{f.k}</b></span>
                        ))}
                        {s.faltan.length > 2 ? ", entre otras cosas" : ""}. Con eso dejo de suponer.
                      </p>
                    ) : (
                      <p className="sub" style={{ margin: "6px 0 0" }}>Ya me ha contado de todo, señor. Cada pieza suya sale con SUS datos.</p>
                    )}
                  </div>
                );
              })() : null}
              {hechos === null ? (
                <div className="vacio">Un momento, señor…</div>
              ) : hechos.length === 0 ? (
                <div className="vacio">Todavía no he anotado nada, señor. A medida que me cuente cómo le va — qué le funcionó, qué no quiere volver a ver — lo iré apuntando aquí.</div>
              ) : (
                <ul>
                  {hechos.slice().reverse().map((h) => (
                    <li key={h.i}>
                      <span>{h.t}{h.f ? <small>anotado el {h.f}</small> : null}</span>
                      <button className="mcx-olvidar" disabled={olvidando === h.i} onClick={() => olvidarHecho(h.i)}>
                        {olvidando === h.i ? "…" : "Olvidar"}
                      </button>
                    </li>
                  ))}
                </ul>
              )}
              {/* La ficha SIEMPRE a mano: quien la dejó para después la retoma aquí, y
                  quien ya la hizo puede volver — el servidor filtra las que ya contestó. */}
              <div style={{ marginTop: 11, display: "flex", gap: 8, alignItems: "center", justifyContent: "space-between", flexWrap: "wrap" }}>
                <button className="mcx-chip pack" disabled={busy || !!packRun || stage === "ficha"}
                  onClick={() => { setSaberOpen(false); abrirFicha(); }}>📋 Cuénteme más de su negocio</button>
                <button className="mcx-reset" onClick={() => setSaberOpen(false)}>Cerrar</button>
              </div>
            </div>
          ) : null}

          <div className="mcx-msgs" ref={scrollRef}>
            <div className="mcx-msgs-inner">
            {msgs.map((m) => (
              <div key={m.id} className={"mcx-row " + (m.from === "u" ? "u" : "j")}>
                <div className="mcx-bub">
                  {m.text}
                  {m.suggestions ? (
                    <div className="mcx-chips" style={{ marginTop: m.text ? 10 : 0 }}>
                      {showPack ? <button className="mcx-chip pack" disabled={busy || !!packRun} onClick={elegirPack}>🎁 Un pack completo</button> : null}
                      {/* Solo lo que tiene dónde publicarse en SUS redes (ver cabeEn). El filtro va
                          AQUÍ y no en SKILLS_SUG, que se sigue necesitando entera en pushMsg para
                          resolver la etiqueta del botón — y porque pedirla a mano sigue funcionando. */}
                      {SKILLS_SUG.filter((s) => cabeEn(s.id, perfil)).map((s) => <button key={s.id} className="mcx-chip sug" disabled={busy || !!packRun} onClick={() => elegirSkill(s.id)}>{s.label}</button>)}
                    </div>
                  ) : null}
                  {m.durChips ? (
                    <div className="mcx-chips" style={{ marginTop: m.text ? 10 : 0 }}>
                      {DUR_OPTS.map((d) => <button key={d.val} className="mcx-chip sug" disabled={busy || !!packRun} onClick={() => elegirDuracion(d.val, d.label)}>{d.label}</button>)}
                    </div>
                  ) : null}
                  {m.ideas ? (
                    <div className="mcx-chips" style={{ marginTop: m.text ? 10 : 0, flexDirection: "column", alignItems: "flex-start" }}>
                      {m.ideas.map((t, i) => <button key={i} className="mcx-chip sug" style={{ textAlign: "left" }} disabled={busy || !!packRun} onClick={() => usarIdea(t)}>{t}</button>)}
                    </div>
                  ) : null}
                  {m.resume && !resumeDone ? (
                    <div className="mcx-chips" style={{ marginTop: m.text ? 10 : 0 }}>
                      <button className="mcx-chip pack" disabled={busy || !!packRun} onClick={() => reanudarPack(m.resume)}>✅ Sí, termínelo</button>
                      <button className="mcx-chip sug" disabled={busy || !!packRun} onClick={() => descartarResume(m.resume)}>Ya no</button>
                    </div>
                  ) : null}
                  {/* ⭐ «Ya tiene una pieza sobre esto» — la pregunta ANTES de gastar. Dos salidas
                      claras: seguir igual (él manda, a veces QUIERE repetir) o darle la vuelta.
                      La segunda no genera nada todavía: le devuelve la palabra para que escriba
                      el ángulo nuevo, que es suyo, no de la máquina. */}
                  {m.parecidoConfirm ? (
                    <div className="mcx-chips" style={{ marginTop: m.text ? 10 : 0 }}>
                      <button className="mcx-chip pack" disabled={busy} onClick={() => { const mid = m.id, c = m.parecidoConfirm; setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { parecidoConfirm: null }) : x)); generarCon(c.idea, c.skill, c.dur, true); }}>Sí, hágala igual</button>
                      <button className="mcx-chip sug" disabled={busy} onClick={() => { const mid = m.id; setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { parecidoConfirm: null }) : x)); setStage("idea"); jarvis("Como guste, señor. Dígame el ángulo nuevo y se la preparo — por ejemplo, para otro público, o contando el otro lado del mismo tema. 👉"); }}>Mejor otro ángulo</button>
                    </div>
                  ) : null}
                  {m.packConfirm ? (
                    <div className="mcx-chips" style={{ marginTop: m.text ? 10 : 0 }}>
                      <button className="mcx-chip pack" disabled={busy || !!packRun} onClick={() => { const mid = m.id; setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { packConfirm: null }) : x)); armarPack(m.packConfirm, packMenu); }}>🎁 Sí, ármelo</button>
                      <button className="mcx-chip sug" disabled={busy || !!packRun} onClick={() => { const mid = m.id; setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { packConfirm: null }) : x)); jarvis("Como guste, señor. ¿Le preparo una sola pieza entonces?"); }}>Mejor no</button>
                    </div>
                  ) : null}
                  {/* EL OFRECIMIENTO DE LA FICHA. Va con dos botones y no bloquea nada:
                      quien quiera crear ya, crea; la ficha queda a mano en «Lo que sé de
                      usted». Ofrecer y no obligar — si se pone de peaje, la saltan sin leer
                      y acabamos con datos aprobados a ciegas, que es lo que evitamos. */}
                  {/* La SEGUNDA ronda: se ofrece con el porqué delante, nunca encadenada. */}
                  {m.fichaResto && m.fichaResto.length ? (
                    <div className="mcx-chips" style={{ marginTop: m.text ? 10 : 0 }}>
                      <button className="mcx-chip pack" disabled={busy || !!packRun}
                        onClick={() => { const mid = m.id, qs = m.fichaResto;
                          setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { fichaResto: null }) : x));
                          setFicha({ qs: qs, i: 0, resto: [] }); setStage("ficha");
                          jarvis(qs[0].p, { fichaQ: qs[0].k }); }}>Seguimos, son {m.fichaResto.length}</button>
                      <button className="mcx-chip sug" disabled={busy || !!packRun}
                        onClick={() => { const mid = m.id; setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { fichaResto: null }) : x)); jarvis("Como usted diga, señor. Las dejo en «Lo que sé de usted» para cuando quiera. ¿Qué le preparo?", { suggestions: true }); }}>Otro rato</button>
                    </div>
                  ) : null}

                  {m.fichaOferta ? (
                    <div className="mcx-chips" style={{ marginTop: m.text ? 10 : 0 }}>
                      <button className="mcx-chip pack" disabled={busy || !!packRun}
                        onClick={() => { const mid = m.id; setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { fichaOferta: null }) : x)); abrirFicha(); }}>📋 Sí, pregúnteme</button>
                      <button className="mcx-chip sug" disabled={busy || !!packRun}
                        onClick={() => { const mid = m.id; setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { fichaOferta: null }) : x)); jarvis("Como usted diga, señor. Lo dejo a mano en «Lo que sé de usted». ¿Qué le preparo?", { suggestions: true }); }}>Ahora no, quiero crear</button>
                    </div>
                  ) : null}

                  {m.resetConfirm ? (
                    <div className="mcx-chips" style={{ marginTop: m.text ? 10 : 0 }}>
                      <button className="mcx-chip" style={{ borderColor: "rgba(255,150,120,.4)", color: "#ffcab0" }} disabled={busy || !!packRun} onClick={() => { const mid = m.id; setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { resetConfirm: null }) : x)); empezarDeCero(); }}>Sí, empezar de cero</button>
                      <button className="mcx-chip sug" disabled={busy || !!packRun} onClick={() => { const mid = m.id; setMsgs((prev) => prev.map((x) => x.id === mid ? Object.assign({}, x, { resetConfirm: null }) : x)); jarvis("Perfecto, señor. Seguimos como estábamos."); }}>No, seguir aquí</button>
                    </div>
                  ) : null}
                </div>
              </div>
            ))}
            {thinking ? (
              <div className="mcx-row j"><div className="mcx-think"><span className="mcx-td"></span><span className="mcx-td"></span><span className="mcx-td"></span>{thinking.label}</div></div>
            ) : null}
            </div>
          </div>

          <div className="mcx-dock">
            {curQ && (chipsDe(curQ, perfil) || []).length ? (
              <div>
                <span className="mcx-tag">TOQUE UNA OPCIÓN {curQ.multi ? "· PUEDE ELEGIR VARIAS" : ""}</span>
                <div className="mcx-chips">
                  {chipsDe(curQ, perfil).map((c) => {
                    const val = c.val || c.label;
                    const on = curQ.multi && multi.indexOf(val) >= 0;
                    return (
                      <button key={val} className={"mcx-chip" + (on ? " on" : "")} disabled={busy}
                        onClick={() => {
                          if (curQ.multi) { setMulti((prev) => prev.indexOf(val) >= 0 ? prev.filter((x) => x !== val) : prev.concat([val])); }
                          else { responder(val, c.label); }
                        }}>{c.label}</button>
                    );
                  })}
                </div>
                {/* ⚠️ DANNY EN CANARY: «marqué Instagram y el botón Listo no parece un botón
                    para presionar; mejor en la esquina derecha, que es donde se define ese
                    lugar como enviar algo». Tenía razón por partida doble: iba con el MISMO
                    estilo que las opciones (se leía como otra red más que elegir) y colgado
                    al final de la fila. En toda la interfaz, la acción de confirmar vive
                    abajo a la DERECHA — como «Enviar». Ahora va ahí y con el estilo de acción. */}
                {curQ.multi ? (
                  <div className="mcx-multi-go">
                    <span className="mcx-multi-cuenta">{multi.length === 0 ? "Toque las que use" : multi.length === 1 ? "1 red elegida" : multi.length + " redes elegidas"}</span>
                    <button className="mcx-send" disabled={busy || !multi.length}
                      onClick={() => responder(multi.slice(), multi.join(", "))}>Listo</button>
                  </div>
                ) : null}
              </div>
            ) : null}

            {stage === "idea" && chosen === "pack" ? (
              <div style={{ marginBottom: 9 }}>
                {/* 🐛 TRAMPA CAZADA POR DANNY EN CANARY: las 4 vienen YA marcadas, así que
                    tocar "Guion" y "Carrusel" creyendo que los ELEGÍA en realidad los QUITABA
                    — y le salió el kit contrario. Si le pasa a quien lo construyó, le pasa a
                    todos. Ahora el estado se lee de un vistazo: ✓ = va incluida, ＋ = agregar. */}
                <span className="mcx-tag">SU KIT · {packCombo.length} {packCombo.length === 1 ? "PIEZA" : "PIEZAS"} — TOQUE UNA ✓ PARA QUITARLA</span>
                <div className="mcx-chips">
                  {packMenu.map(function (sk) {
                    const on = packCombo.indexOf(sk) >= 0;
                    const solaQueQueda = on && packCombo.length === 1;   // el kit no puede quedar vacío
                    return <button key={sk} className={"mcx-chip" + (on ? " on" : "")} disabled={busy}
                      title={solaQueQueda ? "Su kit necesita al menos una pieza, señor"
                        : (on ? "Va incluida — toque para quitarla" : "No va — toque para agregarla")}
                      onClick={function () { setPackCombo(function (prev) { const has = prev.indexOf(sk) >= 0; if (has) { return prev.length > 1 ? prev.filter(function (x) { return x !== sk; }) : prev; } return packMenu.filter(function (x) { return prev.indexOf(x) >= 0 || x === sk; }); }); }}>
                      {(on ? "✓ " : "＋ ") + (COMBO_CHIP[sk] || sk)}</button>;
                  })}
                </div>
              </div>
            ) : null}

            {stage === "idea" ? (
              <div style={{ marginBottom: 18 }}>
                <span className="mcx-tag">¿NO SABE QUÉ CREAR?</span>
                <div className="mcx-chips">
                  <button className="mcx-chip sug" disabled={busy} onClick={() => demeIdeas()}>💡 No sé, deme ideas de mi nicho</button>
                </div>
              </div>
            ) : null}

            {/* LA FICHA: una pregunta a la vez, con su ejemplo de FORMA en el placeholder
                (nunca un valor copiable) y la salida siempre a mano. */}
            {stage === "ficha" && ficha ? (
              <div>
                {/* ⭐ EL PORCENTAJE TAMBIÉN AQUÍ, mientras contesta. «5 de 7» dice dónde está;
                    el % dice lo que se lleva. Danny: «si no, no sabrá que avanza y se
                    decidirá por abandonar». La barrita sube sola conforme cubre frentes. */}
                <div style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap", marginBottom: 5 }}>
                  <span className="mcx-tag">SU FICHA · {ficha.i + 1} DE {ficha.qs.length}</span>
                  {(() => {
                    const s = cuantoSabe(hechos);
                    return (
                      <span style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 11.5, color: "#9fd8ef" }}>
                        <span style={{ width: 62, height: 5, borderRadius: 3, background: "rgba(126,232,255,.13)", overflow: "hidden", display: "inline-block" }}>
                          <span style={{ display: "block", height: "100%", width: s.pct + "%", borderRadius: 3,
                            transition: "width .5s ease", background: "linear-gradient(90deg,#7ee8ff,#a0ffd8)" }} />
                        </span>
                        le conozco al <b style={{ color: s.pct >= 75 ? "#7ee8ff" : "#ffd08a" }}>{s.pct}%</b>
                      </span>
                    );
                  })()}
                </div>
                <div className="mcx-inrow">
                  <textarea className="mcx-input" value={input} disabled={busy}
                    placeholder={ficha.qs[ficha.i].ph || "Escriba su respuesta, señor…"}
                    onChange={(e) => setInput(e.target.value)}
                    onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (input.trim()) avanzarFicha(true, input.trim()); } }} />
                  <button className="mcx-send" disabled={busy || !input.trim()}
                    onClick={() => { if (input.trim()) avanzarFicha(true, input.trim()); }}>Guardar</button>
                </div>
                <div className="mcx-chips" style={{ marginTop: 8 }}>
                  {/* ⚠️ CAMBIADO EL 28-jul, idea de Danny: «no es lo ideal que salga eso —
                      si uno quiere tener un buen resultado no tienes que incitarle a que
                      abandone». Tiene razón: «Dejarlo para después» era una puerta de
                      salida que no aporta nada (si quiere parar, cierra).
                      Pero el salto se QUEDA, con otro nombre. Hay dos motivos muy distintos
                      para saltar: «no me apetece» (malo) y «esto no aplica a mi negocio»
                      (legítimo y necesario). A un barbero al que le preguntes por el
                      delivery hay que dejarle decir que no aplica: si lo obligas, se
                      inventa una respuesta — y un dato FALSO en su ficha es peor que un
                      hueco, porque JARVIS lo usará en todas sus piezas. Mismo botón, otra
                      intención: ya no invita a abandonar, invita a ser preciso. */}
                  <button className="mcx-chip" disabled={busy} onClick={() => avanzarFicha(false)}>No aplica a mi negocio</button>
                </div>
              </div>
            ) : null}

            {(stage === "idea" || (stage === "discovery" && curQ && (!(chipsDe(curQ, perfil) || []).length || curQ.free))) ? (
              <div className="mcx-inrow">
                <textarea className="mcx-input" value={input} disabled={busy}
                  placeholder={stage === "idea" ? (chosen === "pack" ? "Escriba el tema de su pack, señor…" : "Escriba su idea, señor…") : (curQ && curQ.ph) || "Escriba su respuesta, señor…"}
                  onChange={(e) => setInput(e.target.value)}
                  onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (stage === "idea") generar(); else if (input.trim()) responder(input.trim(), input.trim()); } }} />
                <button className="mcx-send" disabled={busy || !input.trim()}
                  onClick={() => { if (stage === "idea") generar(); else if (input.trim()) responder(input.trim(), input.trim()); }}>
                  {stage === "idea" ? (chosen === "pack" ? "Armar pack" : "Generar") : "Enviar"}
                </button>
              </div>
            ) : null}

            {stage === "ready" && chatLibre ? (
              <div className="mcx-inrow">
                <textarea className="mcx-input" value={input} disabled={busy}
                  placeholder="Escríbame lo que necesita, señor…"
                  onChange={(e) => setInput(e.target.value)}
                  onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (input.trim()) conversar(input.trim()); } }} />
                <button className="mcx-send" disabled={busy || !input.trim()} onClick={() => { if (input.trim()) conversar(input.trim()); }}>Enviar</button>
              </div>
            ) : null}
          </div>
        </div>

        {/* ---------- DERECHA: el estudio (archivo ORGANIZADO) ---------- */}
        <div className="mcx-right">
          <div className="mcx-arch-head">
            <span>🗂️ Su estudio</span>
            <span className="mcx-arch-count">{archive.length ? archive.length + " pieza" + (archive.length > 1 ? "s" : "") : ""}</span>
          </div>
          {igPublishOn ? (
            <div style={{ padding: "0 2px 8px", display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
              {igChip()}
              {igDiscConfirm && igConn && igConn.connected ? (
                <span style={{ fontSize: 11.5, opacity: .92 }}>¿Desconectar?{" "}
                  <button style={{ fontSize: 11, padding: "2px 9px", borderRadius: 999, border: "1px solid rgba(255,150,120,.45)", background: "transparent", color: "#ffcab0", cursor: "pointer" }} disabled={igBusy} onClick={function () { setIgDiscConfirm(false); desconectarIG(); }}>Sí</button>{" "}
                  <button style={{ fontSize: 11, padding: "2px 9px", borderRadius: 999, border: "1px solid rgba(255,255,255,.22)", background: "transparent", color: "#cfe0ff", cursor: "pointer" }} onClick={function () { setIgDiscConfirm(false); }}>No</button>
                </span>
              ) : null}
            </div>
          ) : null}
          {archive.length ? (
            <div className="mcx-arch-tools">
              <input className="mcx-search" type="text" placeholder="🔍 Buscar una pieza…" value={query} onChange={(e) => setQuery(e.target.value)} />
              {tiposPresentes.length > 1 ? (
                <div className="mcx-arch-filters">
                  <button className={"mcx-fchip" + (!filterType ? " on" : "")} onClick={() => setFilterType(null)}>Todos</button>
                  {tiposPresentes.map((t) => <button key={t.kind} className={"mcx-fchip" + (filterType === t.kind ? " on" : "")} onClick={() => setFilterType(filterType === t.kind ? null : t.kind)}>{t.icon} {t.label}</button>)}
                </div>
              ) : null}
            </div>
          ) : null}
          <div className="mcx-arch-list">
            {archive.length === 0 && !(packRun && packById[packRun])
              ? <div className="mcx-arch-empty"><span className="em">✨</span>Aquí se irá archivando todo lo que cree, señor.<br />Pídame algo en el chat y aparecerá aquí.</div>
              : (pendientes.length || firmadas.length || packRun)
                ? [
                    (pendientes.length || (packRun && packById[packRun])) ? (
                      <div className="mcx-sec" key="pend">
                        <div className="mcx-sec-head">⏳ Pendientes de su firma{pendientes.length ? " · " + pendientes.length : ""}</div>
                        {packOrder.map(renderPackGroup)}
                        {pendSueltas.map(renderPieza)}
                      </div>
                    ) : null,
                    firmadas.length ? (
                      <div className="mcx-sec" key="firm">
                        <div className="mcx-sec-head">
                          ✓ Firmadas · {firmadas.length}
                          <button className="mcx-sec-toggle" onClick={() => setShowFirmadas(!showFirmadas)}>{showFirmadas ? "▾ Minimizar" : "▸ Mostrar las " + firmadas.length}</button>
                        </div>
                        {showFirmadas ? firmadas.map(renderPieza) : null}
                      </div>
                    ) : null,
                  ]
                : <div className="mcx-arch-empty" style={{ padding: 24 }}>Nada coincide con su búsqueda, señor.</div>}
          </div>
        </div>
      </div>
    );
  }

  // ---------- barra de MARCAS (portafolio / multi-nicho) ----------
  function NichoBar(props) {
    const nichos = props.nichos || [], activo = props.activo || null;
    const [modo, setModo] = useState(null);   // null | 'nuevo' | 'renombrar' | 'borrar'
    const [txt, setTxt] = useState("");
    const activeName = activo ? (((nichos.filter(function (n) { return n.id === activo; })[0]) || {}).nombre || "marca") : (props.nombreDefault || "Marca principal");
    function cerrar() { setModo(null); setTxt(""); }
    function abrirRenombrar() { setModo("renombrar"); setTxt(activeName); }
    function confirmar() {
      const v = txt.trim(); if (!v) return;
      if (modo === "nuevo") props.onCrear(v);
      else if (modo === "renombrar") props.onRenombrar(activo, v);   // activo=null → renombra la marca principal
      cerrar();
    }
    return (
      <div className="mcx-nichobar">
        <span className="mcx-nichobar-lbl">🏦 MARCAS</span>
        <button className={"mcx-nchip" + (!activo ? " on" : "")} disabled={props.busy} onClick={() => props.onCambiar(null)}>{props.nombreDefault || "Marca principal"}</button>
        {nichos.map((n) => <button key={n.id} className={"mcx-nchip" + (activo === n.id ? " on" : "")} disabled={props.busy} onClick={() => props.onCambiar(n.id)}>{n.nombre}</button>)}
        <span className="mcx-nacts">
        {modo === "nuevo" || modo === "renombrar" ? (
          <span className="mcx-nnew">
            <input className="mcx-ninput" autoFocus value={txt} placeholder={modo === "nuevo" ? "Nombre de la marca…" : "Nuevo nombre…"} maxLength={40}
              onChange={(e) => setTxt(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Enter") confirmar(); if (e.key === "Escape") cerrar(); }} />
            <button className="mcx-nchip" onClick={confirmar}>{modo === "nuevo" ? "Crear" : "Guardar"}</button>
            <button className="mcx-nchip" onClick={cerrar}>✕</button>
          </span>
        ) : modo === "borrar" ? (
          <span className="mcx-nnew">
            <span className="mcx-nichobar-lbl" style={{ color: "#ffcab0" }}>¿Borrar «{activeName}»? (se archiva, recuperable)</span>
            <button className="mcx-nchip mut" onClick={() => { props.onBorrar(activo); cerrar(); }}>Sí, borrar</button>
            <button className="mcx-nchip" onClick={cerrar}>No</button>
          </span>
        ) : (
          <span className="mcx-nnew">
            {nichos.length < 6
              ? <button className="mcx-nchip add" disabled={props.busy} onClick={() => { setModo("nuevo"); setTxt(""); }}>＋ Nueva marca</button>
              : <button className="mcx-nchip" disabled>Máximo 6 marcas</button>}
            <button className="mcx-nchip" disabled={props.busy} title="Renombrar la marca activa" onClick={abrirRenombrar}>✏️ Renombrar</button>
            {activo ? <button className="mcx-nchip mut" disabled={props.busy} title="Borrar esta marca" onClick={() => setModo("borrar")}>🗑️</button> : null}
            <span className="mcx-nichobar-lbl" style={{ marginLeft: 4 }}>{nichos.length}/6</span>
          </span>
        )}
        </span>
      </div>
    );
  }

  // ---------- raíz: posee la MARCA ACTIVA y REMONTA el panel al cambiar (key=nicho) ----------
  function MCStudioRoot(props) {
    const flags = (props && props.flags) || {};
    const multi = !!flags.multiNicho;
    const [nichos, setNichos] = useState(Array.isArray(flags.nichos) ? flags.nichos : []);
    const [nombreDefault, setNombreDefault] = useState(flags.nombreDefault || "Marca principal");
    const [nichoId, setNichoId] = useState(function () {
      let n = "";
      try { n = (window.MCNet.nichoActivo && window.MCNet.nichoActivo()) || ""; } catch (e) {}
      if (!n && flags.nichoActivo) { n = flags.nichoActivo; try { window.MCNet.setNichoActivo(n); } catch (e) {} }   // cross-device
      if (n && Array.isArray(flags.nichos) && !flags.nichos.some(function (x) { return x.id === n; })) { n = ""; try { window.MCNet.setNichoActivo(""); } catch (e) {} }   // se borró en otro lado
      return n || null;
    });
    const [panelBusy, setPanelBusy] = useState(false);   // trabajo en curso en el panel (pack/generación) → no cambiar de marca
    function cambiar(id) {
      if (panelBusy) return;   // evita fuga: no cambiar de marca a mitad de un pack/generación
      const norm = id || null;
      if (norm === nichoId) return;
      try { window.MCNet.setNichoActivo(norm || ""); } catch (e) {}
      try { window.MCNet.switchNicho(norm || ""); } catch (e) {}   // persiste cross-device (best-effort)
      setNichoId(norm);   // key cambia → MCChatPanel se remonta → carga la marca nueva
    }
    async function crear(nombre) {
      if (panelBusy) return;
      try {
        const r = await window.MCNet.createNicho(nombre);
        if (r && r.ok && r.nicho) { setNichos(r.lista || []); try { window.MCNet.setNichoActivo(r.nicho.id); } catch (e) {} setNichoId(r.nicho.id); }
      } catch (e) {}
    }
    async function renombrar(id, nombre) {
      if (panelBusy) return;
      try {
        const r = await window.MCNet.renameNicho(id || "", nombre);
        if (r && r.ok) { setNichos(r.lista || []); if (r.nombre_default) setNombreDefault(r.nombre_default); }
      } catch (e) {}
    }
    async function borrar(id) {
      if (panelBusy || !id) return;   // la marca principal (null) NO se borra
      try {
        const r = await window.MCNet.deleteNicho(id);
        if (r && r.ok) {
          setNichos(r.lista || []);
          if (nichoId === id) { try { window.MCNet.setNichoActivo(""); } catch (e) {} setNichoId(null); }   // era la activa → vuelve a la principal
        }
      } catch (e) {}
    }
    return (
      <div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0, width: "100%", display: "flex", flexDirection: "column" }}>
        {multi ? <NichoBar nichos={nichos} nombreDefault={nombreDefault} activo={nichoId} busy={panelBusy} onCambiar={cambiar} onCrear={crear} onRenombrar={renombrar} onBorrar={borrar} /> : null}
        <MCChatPanel key={nichoId || "default"} nicho={nichoId} nichoNombre={nichoId ? (((nichos.filter(function (n) { return n.id === nichoId; })[0]) || {}).nombre || "esta marca") : nombreDefault} onBusy={setPanelBusy} />
      </div>
    );
  }

  // ---------- auto-montaje (solo canary + flag chat) ----------
  function mount() {
    if (document.getElementById("mcx-root")) return;
    if (!(window.MCNet && window.MCNet.flags && window.ReactDOM)) return;
    window.MCNet.flags().then((r) => {
      if (!(r && r.ok && r.chat)) return;
      const host = document.getElementById("view-content");
      const anchor = document.getElementById("content-react-root");
      if (!host || document.getElementById("mcx-root")) return;
      injectCss();
      const el = document.createElement("div"); el.id = "mcx-root";
      el.style.cssText = "flex:1 1 auto;min-height:0;min-width:0;width:100%;display:flex;";
      // .mc-view es flex-ROW con overflow:hidden. El estudio (dos paneles) ocupa toda la vista.
      // Ocultamos el panel clásico: el chat + el estudio lo reemplazan (generar/aprobar/archivo).
      try { host.style.flexDirection = "row"; host.style.overflow = "hidden"; } catch (e) {}
      if (anchor) { try { anchor.style.display = "none"; } catch (e) {} }
      if (anchor && anchor.parentNode === host) host.insertBefore(el, anchor); else host.appendChild(el);
      try { ReactDOM.createRoot(el).render(<MCStudioRoot flags={r} />); } catch (e) { try { ReactDOM.render(<MCStudioRoot flags={r} />, el); } catch (_) {} }
    }).catch(function () {});
  }

  window.MCChatPanel = MCChatPanel;
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mount);
  else mount();
})();
