/* Pagina.jsx — la HOME real tras la transición de la tecla.
   Apertura: nav sticky + hero (el wordmark donde aterriza el punto) + manifiesto.
   Compuesta con el design system sobre window.RmorenodotcomDesignSystem_019de6,
   montada en #page. Reglas de sistema mandan: dark-adaptive, 85/15, voz nosotros. */

const DS = window.RmorenodotcomDesignSystem_019de6 || window;
const { Wordmark, Button, Clock, Eyebrow, DotIcon, DotField, Input, Select, Textarea, Tag } = DS;
const MapaEstelar = window.MapaEstelar;
const HeroGlobe = window.HeroGlobe;
const AsciiPortrait = window.AsciiPortrait;
const DotMorph = window.DotMorph;

/* ── i18n · ES (default, México) / EN (US). El selector vive en el navbar. Cambiar
   idioma re-renderiza toda la home; MapaEstelar escucha 'rm-lang' por su cuenta. */
const I18N = {
  es: {
    'nav.web': 'Web', 'nav.ecommerce': 'E-commerce', 'nav.brand': 'Marca', 'nav.continuo': 'Redes', 'nav.portal': 'portal', 'nav.contacto': 'contacto', 'nav.works': 'trabajos', 'nav.manifiesto': 'manifiesto', 'nav.servicios': 'servicios',
    'hero.eyebrow': 'Mexicali, BC · Estudio de diseño digital',
    'hero.head': 'Eliges lo que necesitas. Precio fijo, tiempo claro.',
    'hero.cta': 'cuéntanos tu proyecto', 'hero.quiet': 'explora el ecosistema ↓', 'backtop.label': 'volver arriba',
    'shint.title': 'Esta página funciona con scroll',
    'shint.cue': 'sigue bajando', 'shint.close': 'cerrar',
    'manifesto.eyebrow': 'manifiesto',
    'manifesto.text': 'Ideamos contigo, planeamos a tu medida e integramos cada pieza hasta que todo encaja. Así una idea se vuelve algo funcional',
    'steps.eyebrow': 'el proceso', 'steps.title': 'cómo trabajamos', 'steps.unit': 'paso',
    'steps.arrival': 'Recorriste el ecosistema. Acoplamos en la estación — así es como trabajamos.',
    'steps.station': 'estación rmx-00', 'steps.bay': 'bahía de trabajo', 'steps.docked': 'acoplado',
    'steps.1.l': 'elige', 'steps.1.d': 'Eliges el servicio que necesitas.',
    'steps.2.l': 'cotiza', 'steps.2.d': 'Cotización clara: precio fijo y tiempo definido.',
    'steps.3.l': 'ejecuta', 'steps.3.d': 'Ejecutamos en el plazo acordado.',
    'steps.4.l': 'entrega', 'steps.4.d': 'Entregamos listo para usar.',
    'person.eyebrow': 'quién está detrás · cómo trabajamos', 'person.title': 'No hablas con una agencia. Hablas conmigo.',
    'person.flow': 'Tomamos tu idea, le ponemos precio y tiempo, y la entregamos lista:',
    'person.body': 'Soy Rubén. Yo cotizo, yo diseño y yo te respondo — sin intermediarios ni promesas que no pueda cumplir. Cuando decimos sí, cumplimos. Cuando decimos no, también.',
    'person.name': 'Rubén Alberto Moreno Fonseca', 'person.role': 'fundador · diseñador',
    'person.bodyShort': 'Soy Rubén. Yo cotizo, yo diseño y yo te respondo — sin intermediarios.',
    'person.t1': 'respondo yo mismo', 'person.t2': 'en menos de 24 h hábiles', 'person.t3': 'desde Mexicali, B.C',
    'person.status': 'en línea', 'person.cta': 'click aquí para hablar', 'person.photo': 'Suelta tu foto profesional',
    'proceso.kicker': 'cómo trabajamos · te llevo de la mano',
    'proceso.s0.t': 'Te llevo de la mano',
    'proceso.s0.d': 'Antes de cotizar, déjame enseñarte cómo trabajo. Cuatro pasos, sin intermediarios ni letras chiquitas. Baja y te explico.',
    'proceso.s1.t': 'Eliges',
    'proceso.s1.d': 'Me cuentas qué necesitas y yo te ayudo a aterrizarlo en un servicio claro — ni de más, ni de menos.',
    'proceso.s2.t': 'Cotizo',
    'proceso.s2.d': 'Te doy precio fijo y tiempo exacto antes de empezar. Lo que ves es lo que pagas; sin sorpresas a medio camino.',
    'proceso.s3.t': 'Ejecuto',
    'proceso.s3.d': 'Me pongo manos a la obra y te muestro cada avance. Nada sigue adelante sin tu visto bueno.',
    'proceso.s4.t': 'Entrego',
    'proceso.s4.d': 'Recibes todo listo para usar, con archivos y respaldo. Y sigo aquí por si necesitas algo más.',
    'proceso.r1': 'elige', 'proceso.r2': 'cotiza', 'proceso.r3': 'ejecuta', 'proceso.r4': 'entrega',
    'proceso.hint': 'baja para empezar', 'proceso.signoff': 'soy Rubén · y te respondo yo mismo',
    'proceso.stepword': 'paso', 'proceso.introIdx': 'el proceso · 4 pasos',
    'works.eyebrow': 'el trabajo habla', 'works.count': 'proyectos · mexicali & más allá', 'works.cta': 'ver todos los proyectos',
    'lead.eyebrow': 'el siguiente paso', 'lead.title': 'cuéntanos qué quieres construir',
    'lead.name': 'Nombre', 'lead.namePh': '¿Cómo te llamas?', 'lead.email': 'Email', 'lead.emailPh': 'tucorreo@dominio.com',
    'lead.cat': '¿Qué quieres construir?', 'lead.msg': 'Cuéntanos', 'lead.msgPh': 'Una línea de qué necesitas o a dónde quieres llegar.',
    'lead.cta': 'empecemos',
    'opt.web': 'Web', 'opt.ecommerce': 'E-commerce', 'opt.brand': 'Marca', 'opt.continuo': 'Redes', 'opt.noseguro': 'No estoy seguro',
    'quote.tag': 'esperando confirmación', 'quote.sub': 'Precio fijo y tiempo definido. Sin sorpresas, sin letra chica.',
    'quote.cta': 'agenda una llamada', 'quote.quiet': 'recibe tu cotización formal',
    'quote.headA': 'listo, ', 'quote.headB': ' — aquí está tu estimado', 'quote.va': 'va', 'quote.time': 'entrega en ',
    'mos.display': 'trabajemos juntos', 'mos.sub': 'Eliges lo que necesitas, te damos precio fijo y tiempo claro. Respondemos en menos de 24 h hábiles.',
    'mos.human': 'Ing. Rubén Moreno', 'mos.humanState': '5 años de experiencia',
    'mos.avail': 'disponible para proyecto', 'mos.availDate': '· junio 2026',
    'mos.callA': '¿Prefieres hablarlo? ', 'mos.callLink': 'agenda una llamada', 'mos.callB': ' de 20 min.',
    'mos.scope': '¿Qué tan grande?', 'mos.submit': 'ver mi estimado', 'mos.note': 'Precio al instante. Sin compromiso.',
    'mos.contOpt': 'opcional', 'mos.contQ': '¿Lo mantenemos al día después?', 'mos.contSub': 'Diseño recurrente, mes con mes — opcional. Lo sumas ahora o lo decides después, sin compromiso anual.',
    'mos.fixed': 'arranca en', 'mos.deliver': 'entrega en ', 'mos.deliverLbl': 'entrega', 'mos.from': 'desde', 'mos.monthly': '/mes · continuo',
    'mos.disc': '“desde” es tu tarifa de arranque, no un precio cerrado. El alcance final lo definimos juntos en una llamada de 20 min — sin compromiso.',
    'mos.agenda': 'agendar llamada', 'mos.wa': 'whatsapp', 'mos.restart': 'empezar de nuevo', 'mos.back': 'cambiar algo',
    'mos.foot': 'Te responde Rubén, en persona — en menos de 24 h hábiles.',
    'mos.tag.web': 'Sitios y plataformas.', 'mos.tag.ecommerce': 'Tiendas que venden.', 'mos.tag.brand': 'Identidad de marca.',
    'footer.tag': 'diseño con punto',
    'hud.channel': 'canal directo · mxli', 'hud.ready': 'listo para transmitir', 'hud.close': 'cerrar',
  },
  en: {
    'nav.web': 'Web', 'nav.ecommerce': 'Online Store', 'nav.brand': 'Brand', 'nav.continuo': 'Social Media', 'nav.portal': 'portal', 'nav.contacto': 'contact', 'nav.works': 'works', 'nav.manifiesto': 'manifesto', 'nav.servicios': 'services',
    'hero.eyebrow': 'Mexicali, BC · Digital design studio',
    'hero.head': 'You pick what you need. Fixed price, clear timeline.',
    'hero.cta': 'tell us your project', 'hero.quiet': 'explore the ecosystem ↓', 'backtop.label': 'back to top',
    'shint.title': 'This page works by scrolling',
    'shint.cue': 'keep going', 'shint.close': 'close',
    'manifesto.eyebrow': 'manifesto',
    'manifesto.text': 'We ideate with you, plan to your size and integrate every piece until it all fits. So an idea becomes something that works',
    'steps.eyebrow': 'the process', 'steps.title': 'how we work', 'steps.unit': 'step',
    'steps.arrival': 'You toured the ecosystem. Now we dock at the station — here’s how we work.',
    'steps.station': 'station rmx-00', 'steps.bay': 'work bay', 'steps.docked': 'docked',
    'steps.1.l': 'pick', 'steps.1.d': 'You pick the service you need.',
    'steps.2.l': 'quote', 'steps.2.d': 'A clear quote: fixed price, defined timeline.',
    'steps.3.l': 'build', 'steps.3.d': 'We execute within the agreed window.',
    'steps.4.l': 'deliver', 'steps.4.d': 'We hand it over ready to use.',
    'person.eyebrow': 'who’s behind this · how we work', 'person.title': 'You’re not talking to an agency. You’re talking to me.',
    'person.flow': 'We take your idea, put a price and timeline on it, and deliver it ready:',
    'person.body': 'I’m Rubén. I quote it, I design it, and I answer you — no middlemen, no promises I can’t keep. When we say yes, we follow through. When we say no, too.',
    'person.name': 'Rubén Alberto Moreno Fonseca', 'person.role': 'founder · designer',
    'person.bodyShort': 'I’m Rubén. I quote it, I design it, and I answer you — no middlemen.',
    'person.t1': 'I answer you myself', 'person.t2': 'in under 24 business hours', 'person.t3': 'from Mexicali, B.C',
    'person.status': 'online', 'person.cta': 'click here to talk', 'person.photo': 'Drop your professional photo',
    'proceso.kicker': 'how we work · I’ll walk you through it',
    'proceso.s0.t': 'I’ll walk you through it',
    'proceso.s0.d': 'Before we talk price, let me show you how I work. Four steps, no middlemen, no fine print. Scroll and I’ll explain.',
    'proceso.s1.t': 'You choose',
    'proceso.s1.d': 'You tell me what you need and I help you shape it into a clear service — no more, no less.',
    'proceso.s2.t': 'I quote',
    'proceso.s2.d': 'You get a fixed price and exact timeline before we start. What you see is what you pay; no surprises midway.',
    'proceso.s3.t': 'I build',
    'proceso.s3.d': 'I get to work and show you every step. Nothing moves forward without your OK.',
    'proceso.s4.t': 'I deliver',
    'proceso.s4.d': 'You get everything ready to use, with files and backup. And I’m still here if you need anything else.',
    'proceso.r1': 'choose', 'proceso.r2': 'quote', 'proceso.r3': 'build', 'proceso.r4': 'deliver',
    'proceso.hint': 'scroll to start', 'proceso.signoff': 'I’m Rubén · and I answer you myself',
    'proceso.stepword': 'step', 'proceso.introIdx': 'the process · 4 steps',
    'works.eyebrow': 'the work speaks', 'works.count': 'projects · mexicali & beyond', 'works.cta': 'view all projects',
    'lead.eyebrow': 'the next step', 'lead.title': 'tell us what you want to build',
    'lead.name': 'Name', 'lead.namePh': 'What’s your name?', 'lead.email': 'Email', 'lead.emailPh': 'you@domain.com',
    'lead.cat': 'What do you want to build?', 'lead.msg': 'Tell us', 'lead.msgPh': 'One line on what you need or where you want to go.',
    'lead.cta': 'let’s start',
    'opt.web': 'Web', 'opt.ecommerce': 'Online Store', 'opt.brand': 'Brand', 'opt.continuo': 'Social Media', 'opt.noseguro': 'Not sure yet',
    'quote.tag': 'awaiting confirmation', 'quote.sub': 'Fixed price, defined timeline. No surprises, no fine print.',
    'quote.cta': 'book a call', 'quote.quiet': 'get your formal quote',
    'quote.headA': 'done, ', 'quote.headB': ' — here’s your estimate', 'quote.va': 'there', 'quote.time': 'delivered in ',
    'mos.display': 'let’s work together', 'mos.sub': 'You pick what you need, we give you a fixed price and a clear timeline. We answer in under 24 business hours.',
    'mos.human': 'Ing. Rubén Moreno', 'mos.humanState': '5 years of experience',
    'mos.avail': 'available for project', 'mos.availDate': '· june 2026',
    'mos.callA': 'Prefer to talk it through? ', 'mos.callLink': 'book a call', 'mos.callB': ' — 20 min.',
    'mos.scope': 'How big?', 'mos.submit': 'see my estimate', 'mos.note': 'Instant price. No commitment.',
    'mos.contOpt': 'optional', 'mos.contQ': 'Keep it fresh afterward?', 'mos.contSub': 'Recurring design, month to month — optional. Add it now or decide later, no annual lock-in.',
    'mos.fixed': 'starts at', 'mos.deliver': 'delivered in ', 'mos.deliverLbl': 'delivery', 'mos.from': 'from', 'mos.monthly': '/mo · ongoing',
    'mos.disc': '“from” is your starting rate, not a closed price. We define the final scope together in a 20-min call — no commitment.',
    'mos.agenda': 'book a call', 'mos.wa': 'whatsapp', 'mos.restart': 'start over', 'mos.back': 'change something',
    'mos.foot': 'Rubén answers you, in person — in under 24 business hours.',
    'mos.tag.web': 'Sites & platforms.', 'mos.tag.ecommerce': 'Stores that sell.', 'mos.tag.brand': 'Brand identity.',
    'footer.tag': 'design with a dot',
    'hud.channel': 'direct channel · mxli', 'hud.ready': 'ready to transmit', 'hud.close': 'close',
  },
};
let LANG = 'es';
try { LANG = localStorage.getItem('rm_lang') || 'es'; } catch (e) {}
function t(k) { return (I18N[LANG] && I18N[LANG][k]) || I18N.es[k] || k; }
const TYPE_I18N = {
  'tienda + marca': 'store + brand', 'tienda + merch': 'store + merch', 'brand development': 'brand development',
  'web + motion': 'web + motion', 'motion graphics': 'motion graphics', 'portal de noticias': 'news portal',
  'diseño web': 'web design', 'motion · lyric': 'motion · lyric', 'catálogo + marca': 'catalog + brand',
  'social media': 'social media', 'desarrollo web': 'web development', 'cover · visual': 'cover · visual',
};
function tType(s) { return LANG === 'en' ? (TYPE_I18N[s] || s) : s; }
function setLang(l) {
  if (l === LANG) return;
  LANG = l;
  try { localStorage.setItem('rm_lang', l); } catch (e) {}
  document.documentElement.setAttribute('lang', l === 'en' ? 'en' : 'es');
  window.dispatchEvent(new CustomEvent('rm-lang', { detail: l }));
  if (window.rmRenderPage) window.rmRenderPage();
}
window.rmGetLang = function () { return LANG; };
window.rmSetLang = setLang;

/* selector de idioma — segmentado ES / EN (México · US), lenguaje de la casa */
function LangToggle({ className }) {
  return (
    <div className={'lang-toggle' + (className ? ' ' + className : '')} role="group" aria-label="idioma / language">
      {[['es', 'ES', 'Español (México)'], ['en', 'EN', 'English (US)']].map(([code, label, full]) => (
        <button key={code} type="button" className={'lang-opt' + (LANG === code ? ' on' : '')}
          aria-pressed={LANG === code} title={full} onClick={() => setLang(code)}>{label}</button>
      ))}
    </div>
  );
}

/* marca de tres puntos de la casa (no un hamburger genérico) */
function ThreeDots({ size = 5, gap = 5, color = 'currentColor' }) {
  return (
    <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center', gap }}>
      {[0, 1, 2].map((i) => (
        <span key={i} style={{ width: size, height: size, borderRadius: '50%', background: color, display: 'block' }} />
      ))}
    </span>
  );
}
function MoreMark({ size = 20 }) {
  return DotIcon ? <DotIcon name="more" size={size} /> : <ThreeDots />;
}

/* fondo del hero estilo "lanes": banners de texto que se desplazan en sentidos
   alternos detrás del wordmark — vida tipográfica, lenguaje de la casa (no foto). */
const CAT_OF = { web: 'var(--cat-azure)', 'e-commerce': 'var(--cat-verde)', brand: 'var(--cat-sienna)', continuo: 'var(--cat-yellow)' };
const LANES = [
  { dir: 'l', dur: '54s', words: ['diseño con punto'] },
  { dir: 'r', dur: '46s', tint: true, words: ['web', 'e-commerce', 'brand', 'continuo'] },
  { dir: 'l', dur: '64s', words: ['precio fijo', 'tiempo claro'] },
  { dir: 'r', dur: '50s', words: ['mexicali', 'estudio de diseño digital'] },
  { dir: 'l', dur: '42s', words: ['hecho con punto', 'a tu medida'] },
  { dir: 'r', dur: '58s', tint: true, words: ['continuo', 'brand', 'e-commerce', 'web'] },
  { dir: 'l', dur: '48s', words: ['empezó con un .com', 'el punto es el origen'] },
];
function HeroLanes() {
  const reps = 6;
  function group(lane, gi) {
    const items = [];
    for (let r = 0; r < reps; r++) {
      lane.words.forEach((w, wi) => {
        const tint = lane.tint ? CAT_OF[w] : null;
        items.push(<span className="lane-word" key={gi + r + '-' + wi} style={tint ? { '--stroke': 'color-mix(in srgb, ' + tint + ' 55%, transparent)' } : undefined}>{w}</span>);
        items.push(<span className="lane-sep" key={gi + r + '-' + wi + 's'} aria-hidden="true">·</span>);
      });
    }
    return <span className="lane-group" key={gi}>{items}</span>;
  }
  return (
    <div className="hero-lanes" aria-hidden="true">
      {LANES.map((lane, i) => (
        <div className={'lane lane--' + lane.dir + (lane.solid ? ' solid' : '')} key={i} style={{ '--dur': lane.dur }}>
          {group(lane, 'a')}{group(lane, 'b')}
        </div>
      ))}
    </div>
  );
}

/* nav: las cuatro categorías tiñen su subrayado; cada sección lleva su color de marca */
const NAV = [
  ['/#manifiesto', 'nav.manifiesto', 'var(--cat-sienna)'],
  ['/#mapa', 'nav.servicios', 'var(--cat-azure)'],
  ['/works', 'nav.works', 'var(--cat-yellow)'],
  ['/#contacto', 'nav.contacto', 'var(--cat-verde)'],
];
// los 4 servicios individuales (viajar directo a cada página de servicio)
const NAV_SVC = [
  ['/web', 'nav.web', 'var(--cat-azure)', '01'],
  ['/ecommerce', 'nav.ecommerce', 'var(--cat-verde)', '02'],
  ['/brand', 'nav.brand', 'var(--cat-sienna)', '03'],
  ['/continuo', 'nav.continuo', 'var(--cat-yellow)', '04'],
];

// "servicios" PASA por la transición "entrando a los servicios" y aterriza en el
// primer servicio (web · 01/04): scroll suave al frame ~0.30 del track del mapa,
// pasando el warp (WARP_END = 0.26) → la transición se ve en el camino.
// UNDO: SERVICIOS_VIA_INTRO = false (vuelve al destino anterior, 0.11).
const SERVICIOS_VIA_INTRO = true;
function goToServiciosIntro() {
  const wrap = document.getElementById('map-wrap');
  if (!wrap) return false;
  const total = wrap.scrollHeight - window.innerHeight;
  const frac = 0.30;
  
  if (SERVICIOS_VIA_INTRO && window.scrollY > wrap.offsetTop + 4) {
    window.scrollTo({ top: wrap.offsetTop, behavior: 'auto' });
  }

  const startY = window.scrollY;
  const targetY = Math.round(wrap.offsetTop + total * frac);
  const duration = 1800; // ms (give it time to play the warp animation)
  const startTime = performance.now();

  function easeInOutQuad(t) {
    return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
  }

  function step(time) {
    let elapsed = time - startTime;
    if (elapsed > duration) elapsed = duration;
    const progress = easeInOutQuad(elapsed / duration);
    window.scrollTo(0, startY + (targetY - startY) * progress);
    
    if (elapsed < duration) {
      requestAnimationFrame(step);
    }
  }
  requestAnimationFrame(step);

  return true;
}

function NavBar() {
  const navClick = (e, h) => {
    if (h.endsWith('#mapa')) { e.preventDefault(); goToServiciosIntro(); }
  };
  const links = NAV.map(([h, l, c]) => ({ href: h, label: t(l), cat: c }));
  const svcItems = NAV_SVC.map(([h, l, c, n]) => ({ href: h, label: t(l), cat: c, num: n }));
  return (
    <RMNavbar wordmarkHref="#top" links={links}
      svcHref="/#mapa" svcItems={svcItems}
      headLabel={LANG === 'en' ? 'navigation' : 'navegación'}
      lang={LANG} onPickLang={setLang} onNavClick={navClick} />
  );
}

function Hero() {
  return (
    <section id="top" className="hero" data-theme="dark">
      {HeroGlobe ? <HeroGlobe /> : null}
      <span className="hero-eyebrow reveal"><i className="pip" aria-hidden="true" />{t('hero.eyebrow')}</span>
      <h1 className="hero-wm">rmorenodot<i id="heroDot" className="hero-dot" aria-hidden="true" />com</h1>
      <p className="hero-head reveal">{t('hero.head')}</p>
      <div className="hero-cta reveal">
        <Button intent="waiting" variant="dotted" arrow href="#contacto" data-hero-cta="1"
          onClick={(e) => {
            e.preventDefault();
            const el = document.getElementById('contacto');
            const y = el ? el.getBoundingClientRect().top + window.scrollY - 24 : 0;
            window.dispatchEvent(new CustomEvent('rm-backtop-show'));
            if (window.rmWarpScroll) window.rmWarpScroll(y);
            else if (el) window.scrollTo({ top: y, behavior: 'smooth' });
          }}>{t('hero.cta')}</Button>
      </div>

    </section>
  );
}

const MANIFESTO = 'Ideamos contigo, planeamos a tu medida e integramos cada pieza hasta que todo encaja. Así una idea se vuelve algo funcional';

function Manifiesto() {
  React.useEffect(() => {
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (reduce) return;
    const sec = document.getElementById('manifiesto');
    if (!sec) return;
    const words = Array.prototype.slice.call(sec.querySelectorAll('.mword'));
    const acc = sec.querySelector('.acc');
    const DIM = 'color-mix(in srgb, var(--fg) 20%, transparent)';
    words.forEach((w) => { w.style.color = DIM; });
    const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
    const lerp = (a, b, t) => a + (b - a) * t;
    const smooth = (t) => t * t * (3 - 2 * t);
    // El PUNTO de “cumplimos.” se despega, viaja al centro y se queda como el
    // punto-origen del warp — la transición es un solo punto continuo. Vive en
    // <body> (fixed) para no recortarse ni irse con el manifiesto al despinnear.
    const dot = document.createElement('i');
    dot.className = 'mani-handoff';
    document.body.appendChild(dot);
    let raf = 0;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = 0;
        const r = sec.getBoundingClientRect();
        const total = sec.offsetHeight - window.innerHeight;
        const rawProg = total > 0 ? -r.top / total : 0;
        const prog = clamp(rawProg, 0, 1);
        // las palabras se encienden en los primeros ~66% del track (durante el pin).
        const p = clamp(prog / 0.66, 0, 1);
        const lit = Math.round(p * words.length);
        for (let i = 0; i < words.length; i++) words[i].style.color = i < lit ? 'var(--fg)' : DIM;
        // ── handoff del punto: despega → se sostiene centrado mientras el
        // manifiesto sale y la galaxia entra → cede al punto del warp.
        // El inicio del join (cuánto scroll esperar tras encender el texto) es
        // ajustable en vivo desde el slider "Delay del join" (Tweaks).
        const joinStart = (typeof window.__maniJoinDelay === 'number') ? window.__maniJoinDelay : 0.70;
        const joinSpan = Math.max(0.05, 1 - joinStart);
        const moveT = clamp((prog - joinStart) / joinSpan, 0, 1);
        const ease = smooth(moveT);
        const appear = clamp(moveT / 0.16, 0, 1);
        const fade = clamp((rawProg - 1.0) / 0.22, 0, 1);   // cede de inmediato: el mapa se solapa y fija casi al centrar
        const vis = appear * (1 - fade);
        if (acc) acc.style.opacity = String(clamp(1 - moveT / 0.12, 0, 1));
        if (vis <= 0.001) { dot.style.opacity = '0'; }
        else {
          const ar = acc ? acc.getBoundingClientRect() : { left: window.innerWidth / 2, top: window.innerHeight / 2, width: 0, height: 0 };
          const sx = ar.left + ar.width / 2, sy = ar.top + ar.height / 2;
          const cx = window.innerWidth / 2, cy = window.innerHeight / 2;
          const x = lerp(sx, cx, ease), y = lerp(sy, cy, ease);
          const endD = Math.min(window.innerWidth, window.innerHeight) * 0.054;
          const d = lerp(11, endD, ease);
          dot.style.width = d + 'px'; dot.style.height = d + 'px';
          dot.style.opacity = String(vis);
          dot.style.transform = 'translate(-50%,-50%) translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
          dot.style.boxShadow = '0 0 ' + (d * 1.9).toFixed(0) + 'px ' + (d * 0.55).toFixed(0) + 'px rgba(185,101,75,' + (0.5 * ease).toFixed(2) + ')';
        }
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    onScroll();
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      dot.remove();
      if (acc) acc.style.opacity = '';
    };
  }, []);

  const words = t('manifesto.text').split(' ');
  return (
    <section id="manifiesto" className="manifesto" data-theme="dark">
      <div className="manifesto-sticky">
        <div className="inner">
          <hr className="dot-divider" />
          <p className="manifesto-eyebrow"><i className="pip" aria-hidden="true" />{t('manifesto.eyebrow')}</p>
          <p className="manifesto-statement">
            {words.map((w, i) => (
              <span className="mword" key={i}>{w}{i < words.length - 1 ? ' ' : ''}</span>
            ))}<span className="acc">.</span>
          </p>
        </div>
      </div>
    </section>
  );
}

/* ── el ecosistema · folders que se apilan al scroll ───────────────────── */
const FOLDERS = [
  {
    n: '01', label: 'web', nameKey: 'nav.web', cat: 'var(--cat-azure)', mood: 'pulse', onInk: false,
    solves: 'Sitios y landing pages que cargan rápido y están hechos para convertir.',
    files: ['landing de una página', 'sitio multipágina', 'plataforma a medida'],
  },
  {
    n: '02', label: 'e-commerce', nameKey: 'nav.ecommerce', cat: 'var(--cat-verde)', mood: 'rise', onInk: false,
    solves: 'Tiendas en línea listas para vender y fáciles de administrar.',
    files: ['tienda Shopify', 'catálogo + inventario', 'checkout optimizado'],
  },
  {
    n: '03', label: 'brand', nameKey: 'nav.brand', cat: 'var(--cat-sienna)', mood: 'breathe', onInk: false,
    solves: 'Identidad visual con carácter, de la marca al último detalle.',
    files: ['identidad de marca', 'sistema visual', 'guía y assets'],
  },
  {
    n: '04', label: 'continuo', nameKey: 'nav.continuo', cat: 'var(--cat-yellow)', mood: 'chase', onInk: false,
    solves: 'Mantenimiento y redes, mes con mes, sin que tú lo cargues.',
    files: ['mantenimiento web', 'gestión de redes', 'soporte continuo'],
  },
];

function Folder({ f, i }) {
  return (
    <article className={'folder' + (f.onInk ? ' ink-on-fill' : '')} style={{ '--i': i, '--z': i + 1, '--cat': f.cat }}>
      <div className="folder-card">
        <a href={'/' + (f.label === 'continuo' ? 'redes' : f.label === 'brand' ? 'marca' : f.label.replace('-', ''))} className="folder-tab" aria-label={'entrar a ' + t(f.nameKey)}>
          <span className="folder-num">[{f.n}]</span>
          <span className="folder-bullet" aria-hidden="true" />
          <span className="folder-label">{t(f.nameKey).toLowerCase()}</span>
          <span className="folder-tabarrow" aria-hidden="true">{DotIcon ? <DotIcon name="arrow-right" size={16} /> : '→'}</span>
        </a>
        <div className="folder-body">
          <div className="folder-content">
            <h3 className="folder-name">{t(f.nameKey)}</h3>
            <p className="folder-solves">{f.solves}</p>
            <div className="folder-cta">
              <Button intent="neutral" variant="dotted" arrow href={'/' + (f.label === 'continuo' ? 'redes' : f.label === 'brand' ? 'marca' : f.label.replace('-', ''))}>entrar</Button>
            </div>
          </div>
          <div className="folder-files">
            <p className="folder-files-h">archivos</p>
            <hr className="dot-divider" />
            {f.files.map((name, k) => (
              <React.Fragment key={name}>
                <div className="file-row">
                  <span className="file-num">{String(k + 1).padStart(2, '0')}</span>
                  <span className="file-name">{name}</span>
                </div>
                <hr className="dot-divider" />
              </React.Fragment>
            ))}
          </div>
        </div>
      </div>
    </article>
  );
}

function Ecosistema() {
  React.useEffect(() => {
    // cross-fade del capítulo: papel → ink al entrar, regresa al salir.
    const sec = document.getElementById('ecosistema');
    const bd = document.querySelector('.eco-backdrop');
    let raf = 0;
    const onScroll = () => {
      if (raf || !sec || !bd) return;
      raf = requestAnimationFrame(() => {
        raf = 0;
        const r = sec.getBoundingClientRect();
        const vh = window.innerHeight;
        let p = 0;
        if (r.top < vh && r.bottom > 0) {
          const enter = (vh - r.top) / (vh * 0.55);
          const exit = r.bottom / (vh * 0.55);
          p = Math.max(0, Math.min(1, enter, exit));
        }
        bd.style.opacity = String(p);
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    onScroll();
    // settle con micro-overshoot al entrar cada folder
    const io = new IntersectionObserver((es) => {
      es.forEach((e) => { if (e.isIntersecting) e.target.classList.add('in'); });
    }, { threshold: 0.12 });
    document.querySelectorAll('.folder').forEach((el) => io.observe(el));
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      io.disconnect();
    };
  }, []);

  return (
    <React.Fragment>
      <div className="eco-backdrop" aria-hidden="true" />
      <section id="ecosistema" className="eco">
        <div className="eco-inner">
          <p className="eco-eyebrow"><i className="pip" aria-hidden="true" />los servicios</p>
          <h2 className="eco-title">el ecosistema<span className="dot">.</span></h2>
          <p className="eco-lede">Cuatro familias de servicio. Abre la que necesitas — cada una es un mundo con su propio color.</p>
          <div className="eco-stack">
            {FOLDERS.map((f, i) => <Folder key={f.label} f={f} i={i} />)}
          </div>
        </div>
      </section>
    </React.Fragment>
  );
}

/* ticker editorial cruzado (X) — bandas matte papel/ink, Cera Black CAPS,
   pops de color de categoría y punto sienna. */
const TK = [
  ['diseño con punto', null], ['precio fijo', null], ['web', 'var(--cat-azure)'],
  ['tiempo claro', null], ['e-commerce', 'var(--cat-verde)'], ['hecho con punto', null],
  ['brand', 'var(--cat-sienna)'], ['a tu medida', null], ['continuo', 'var(--cat-yellow)'],
  ['el punto es el origen', null],
];
function tkGroup(g) {
  const a = [];
  for (let r = 0; r < 3; r++) TK.forEach(([w, c], i) => {
    a.push(<span className="tx-word" key={g + r + '-' + i} style={c ? { color: c } : undefined}>{w}</span>);
    a.push(<i className="tx-dot" key={g + r + '-' + i + 'd'} aria-hidden="true" />);
  });
  return <span className="tx-group" key={g}>{a}</span>;
}
function TickerBand() {
  return (
    <div className="ticker-cross" aria-hidden="true">
      <div className="tx-band tx-band--a"><div className="tx-row">{tkGroup('a')}{tkGroup('b')}</div></div>
      <div className="tx-band tx-band--b"><div className="tx-row tx-row--rev">{tkGroup('c')}{tkGroup('d')}</div></div>
    </div>
  );
}

const STEPS = ['1', '2', '3', '4'];
const STEP_CATS = ['var(--cat-azure)', 'var(--cat-verde)', 'var(--cat-sienna)', 'var(--cat-yellow)'];
function ProcessSync() {
  const [idx, setIdx] = React.useState(0);   // 0 = rostro · 1..4 = pasos
  React.useEffect(() => {
    const h = (e) => setIdx(e.detail.idx);
    window.addEventListener('dotmorph:step', h);
    return () => window.removeEventListener('dotmorph:step', h);
  }, []);
  const active = idx - 1;
  const steps = ['1', '2', '3', '4'];
  return (
    <div className="proc-sync" aria-hidden="true">
      <ol className="ps-steps">
        {steps.map((n, i) => (
          <li key={n} className={'ps-step' + (i === active ? ' on' : '')} style={{ '--c': STEP_CATS[i] }}>
            <i className="ps-bullet" /><span className="ps-label">{t('steps.' + n + '.l')}</span>
            {i < steps.length - 1 ? <span className="ps-sep" /> : null}
          </li>
        ))}
      </ol>
      <p className="ps-desc" style={active >= 0 ? { '--c': STEP_CATS[active] } : undefined}>
        {active >= 0 ? t('steps.' + steps[active] + '.d') : t('person.flow')}
      </p>
    </div>
  );
}
/* ── PROC_STAGES: el rostro (00) + los 4 pasos, cada uno con su color de
   categoría — sincronizados 1:1 con las shapes del DotMorph (rostro, elige,
   cotiza, ejecuta, entrega). ── */
const PROC_STAGES = [
  { k: '0', num: '',   c: 'var(--cat-sienna)' },
  { k: '1', num: '01', c: 'var(--cat-azure)'  },
  { k: '2', num: '02', c: 'var(--cat-verde)'  },
  { k: '3', num: '03', c: 'var(--cat-sienna)' },
  { k: '4', num: '04', c: 'var(--cat-yellow)' },
];

/* ComoTrabajamos — momento scroll-scrubbed (como el manifiesto/galaxia). El
   PROCESO es el protagonista: te quedas fijo y, conforme bajas, el morph ASCII
   te lleva de la mano del rostro → elige → cotiza → ejecuta → entrega, mientras
   una sola voz guía te explica cada paso. La sección ES la pieza. */
function ComoTrabajamos() {
  React.useEffect(() => {
    const sec = document.getElementById('como');
    if (!sec) return;
    const rows = Array.prototype.slice.call(sec.querySelectorAll('.proc-row'));
    const tl = sec.querySelector('.proc2-timeline');
    const svg = sec.querySelector('.proc-svg');
    const track = sec.querySelector('.proc-svg-track');
    const draw = sec.querySelector('.proc-svg-draw');
    const grad = sec.querySelector('#procGrad');
    const bead = sec.querySelector('.proc-bead');
    const tailPath = sec.querySelector('.proc-svg-tail');
    const N = rows.length;                          // 5 = intro + 4 pasos
    const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
    const COLORS = ['var(--cat-sienna)', 'var(--cat-azure)', 'var(--cat-verde)', 'var(--cat-sienna)', 'var(--cat-yellow)'];
    const HEX = ['#BA6849', '#477AB3', '#618E48', '#BA6849', '#D2B440'];
    let totalLen = 0, tailLen = 0, lastKey = '';
    function buildPath() {
      const tr = tl.getBoundingClientRect();
      const W = tr.width, H = tr.height;
      const ns = rows.map((r) => { const n = r.querySelector('.pr-node').getBoundingClientRect(); return { x: n.left + n.width / 2 - tr.left, y: n.top + n.height / 2 - tr.top }; });
      const top = ns[0].y, bot = ns[N - 1].y, span = Math.max(1, bot - top);
      // nodo del asteroide — SOLO para la cola invisible que recorre el punto
      const astEl = sec.querySelector('.asteroid-cta');
      let astNode = null;
      if (astEl) { const a = astEl.getBoundingClientRect(); astNode = { x: a.left + a.width / 2 - tr.left, y: a.top + a.height * 0.14 - tr.top }; }
      const desktop = window.matchMedia('(min-width: 861px)').matches;
      // amplitud ADAPTATIVA: tan ancha como permita el carril central vacío
      // (entre el borde interior de las columnas), para que serpentee marcado
      // SIN tocar el texto, en cualquier ancho.
      let amp = 0;
      if (desktop) {
        const cx = ns[0].x;
        let rightAlt = -Infinity, leftNon = Infinity;
        rows.forEach((r, i) => {
          r.querySelectorAll('.pr-title, .pr-desc, .pr-num').forEach((e) => {
            const b = e.getBoundingClientRect(); const l = b.left - tr.left, rr = b.right - tr.left;
            if (i % 2 === 1) { if (rr > rightAlt) rightAlt = rr; } else { if (l < leftNon) leftNon = l; }
          });
        });
        const halfLane = Math.min(cx - rightAlt, leftNon - cx);
        amp = Math.max(28, Math.min(180, (halfLane - 18) / 0.75));
      } else {
        // MÓVIL: onda suave hacia el margen izquierdo (vacío); acotada por el
        // borde del texto a la derecha y por la orilla de pantalla a la izquierda.
        const cx = ns[0].x;
        let contentLeft = Infinity;
        rows.forEach((r) => {
          r.querySelectorAll('.pr-num, .pr-title, .pr-desc').forEach((e) => {
            const l = e.getBoundingClientRect().left - tr.left; if (l < contentLeft) contentLeft = l;
          });
        });
        const rightRoom = contentLeft - cx - 12;   // deja aire antes del texto
        const leftRoom = cx + tr.left - 8;          // hasta ~orilla de pantalla
        amp = Math.max(10, Math.min(26, Math.min(rightRoom, leftRoom)));
      }
      let d = 'M ' + ns[0].x.toFixed(1) + ' ' + top.toFixed(1);
      for (let i = 1; i < N; i++) {
        const x0 = ns[i - 1].x, y0 = ns[i - 1].y, x1 = ns[i].x, y1 = ns[i].y;
        const dir = (i % 2) ? 1 : -1;
        const bx = (x0 + x1) / 2 + dir * amp;
        d += ' C ' + bx.toFixed(1) + ' ' + (y0 + (y1 - y0) * 0.32).toFixed(1)
          + ', ' + bx.toFixed(1) + ' ' + (y1 - (y1 - y0) * 0.32).toFixed(1)
          + ', ' + x1.toFixed(1) + ' ' + y1.toFixed(1);
      }
      svg.setAttribute('viewBox', '0 0 ' + W.toFixed(1) + ' ' + H.toFixed(1));
      svg.style.width = W + 'px'; svg.style.height = H + 'px';
      track.setAttribute('d', d); draw.setAttribute('d', d);
      totalLen = draw.getTotalLength();
      draw.style.strokeDasharray = totalLen;
      // cola INVISIBLE: del nodo 04 al asteroide (solo el punto la recorre)
      if (astNode && tailPath) {
        const lx = ns[N - 1].x, ly = ns[N - 1].y, mid = astNode.y - ly;
        const tailD = 'M ' + lx.toFixed(1) + ' ' + ly.toFixed(1)
          + ' C ' + lx.toFixed(1) + ' ' + (ly + mid * 0.42).toFixed(1)
          + ', ' + astNode.x.toFixed(1) + ' ' + (astNode.y - mid * 0.32).toFixed(1)
          + ', ' + astNode.x.toFixed(1) + ' ' + astNode.y.toFixed(1);
        tailPath.setAttribute('d', tailD);
        tailLen = tailPath.getTotalLength();
      }
      // gradiente vertical con bandas SÓLIDAS por sección (no arcoíris)
      grad.setAttribute('gradientUnits', 'userSpaceOnUse');
      grad.setAttribute('x1', 0); grad.setAttribute('y1', top.toFixed(1));
      grad.setAttribute('x2', 0); grad.setAttribute('y2', bot.toFixed(1));
      let s = '<stop offset="0" stop-color="' + HEX[0] + '"/>';
      for (let i = 1; i < N; i++) { const o = (((ns[i].y - top) / span) * 100).toFixed(2) + '%'; s += '<stop offset="' + o + '" stop-color="' + HEX[i - 1] + '"/><stop offset="' + o + '" stop-color="' + HEX[i] + '"/>'; }
      s += '<stop offset="100%" stop-color="' + HEX[N - 1] + '"/>';
      grad.innerHTML = s;
    }
    let raf = 0, lastHit = -1;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = 0;
        const tr = tl.getBoundingClientRect();
        const key = tr.width.toFixed(0) + 'x' + tr.height.toFixed(0) + (window.matchMedia('(min-width: 861px)').matches ? 'd' : 'm');
        if (key !== lastKey) { lastKey = key; buildPath(); }
        const focus = window.innerHeight * 0.46;     // línea de foco
        const centers = rows.map((r) => { const b = r.getBoundingClientRect(); return b.top + b.height * 0.42; });
        const astEl = sec.querySelector('.asteroid-cta');
        if (astEl) { const a = astEl.getBoundingClientRect(); centers.push(a.top + a.height * 0.5); }  // parada final: asteroide
        const NS = centers.length;                    // N + 1 (pasos + asteroide)
        let f = 0;
        if (focus <= centers[0]) f = 0;
        else if (focus >= centers[NS - 1]) f = NS - 1;
        else { for (let i = 0; i < NS - 1; i++) { if (focus >= centers[i] && focus < centers[i + 1]) { f = i + (focus - centers[i]) / (centers[i + 1] - centers[i]); break; } } }
        const prog = clamp(f / (NS - 1), 0, 1);
        window.__processScroll = prog;
        const active = clamp(Math.round(f), 0, N - 1);
        rows.forEach((r, i) => { r.classList.toggle('reached', i <= active); r.classList.toggle('on', i === active); });
        if (astEl) astEl.classList.toggle('arrived', f > N - 0.35);
        // colisión: al encimarse el bead sobre un nodo, sacude el paso entero
        const nf = Math.round(f);
        if (Math.abs(f - nf) < 0.07 && nf !== lastHit) {
          lastHit = nf;
          const r = rows[nf];
          if (r) {
            r.classList.remove('impact'); void r.offsetWidth; r.classList.add('impact');
            const nd = r.querySelector('.pr-node');
            if (nd) { nd.classList.remove('hit'); void nd.offsetWidth; nd.classList.add('hit'); }
          }
        } else if (Math.abs(f - nf) > 0.16) { lastHit = -1; }
        // la LÍNEA se dibuja solo hasta el 04; el PUNTO se desprende y baja al asteroide
        if (draw && bead && totalLen) {
          let pt, beadHex;
          if (f <= N - 1) {
            const pr = clamp(f / (N - 1), 0, 1);
            draw.style.strokeDashoffset = (totalLen * (1 - pr)).toFixed(1);
            pt = draw.getPointAtLength(pr * totalLen);
            beadHex = HEX[active];
          } else {
            draw.style.strokeDashoffset = '0';
            const tp = clamp(f - (N - 1), 0, 1);
            pt = (tailPath && tailLen) ? tailPath.getPointAtLength(tp * tailLen) : draw.getPointAtLength(totalLen);
            beadHex = '#f5f4ef';   // el punto que baja del 04 al asteroide es BLANCO (no rojo)
          }
          bead.style.left = pt.x.toFixed(1) + 'px';
          bead.style.top = pt.y.toFixed(1) + 'px';
          bead.style.background = beadHex;
          bead.style.boxShadow = '0 0 18px 4px ' + beadHex;
        }
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    onScroll();
    return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); };
  }, []);
  return (
    <section id="como" className="proc2" data-theme="dark">
      <div className="proc2-inner">
        <p className="proc-kicker"><i className="pip" aria-hidden="true" />{t('proceso.kicker')}</p>

        {/* solo el caminito vector: línea + bead que viaja, pasos apilados */}
        <div className="proc2-timeline">
          <svg className="proc-svg" aria-hidden="true" preserveAspectRatio="none">
            <defs><linearGradient id="procGrad"></linearGradient></defs>
            <path className="proc-svg-track" fill="none" />
            <path className="proc-svg-draw" fill="none" stroke="url(#procGrad)" />
            <path className="proc-svg-tail" fill="none" stroke="none" />
          </svg>
          <span className="proc-bead" aria-hidden="true" />

          {PROC_STAGES.map((st, i) => (
            <div className={'proc-row' + (i === 0 ? ' proc-row--intro' : '') + (i % 2 === 1 ? ' proc-row--alt' : '')} key={st.k} style={{ '--c': st.c }}>
              <span className="pr-node" aria-hidden="true" />
              {i === 0 ? (
                <React.Fragment>
                  <p className="pr-idx pr-idx--intro">{t('proceso.introIdx')}</p>
                  <h3 className="pr-title">{t('proceso.s0.t')}<i className="pr-dot" aria-hidden="true" /></h3>
                  <p className="pr-desc">{t('proceso.s0.d')}</p>
                  <p className="pr-signoff">{t('proceso.signoff')}</p>
                </React.Fragment>
              ) : (
                <React.Fragment>
                  <span className="pr-num">{st.num}</span>
                  <h3 className="pr-title">{t('proceso.s' + st.k + '.t')}<i className="pr-dot" aria-hidden="true" /></h3>
                  <p className="pr-desc">{t('proceso.s' + st.k + '.d')}</p>
                </React.Fragment>
              )}
            </div>
          ))}

          <div className="proc2-cta">
            <a href="#contacto" className="asteroid-cta" aria-label={t('person.cta')}>
              <span className="asteroid-rock" aria-hidden="true"></span>
              <span className="asteroid-debris" aria-hidden="true"></span>
              <span className="asteroid-label">{t('person.cta')}</span>
            </a>
          </div>
        </div>
      </div>
    </section>
  );
}

/* WORKS — clientes reales (migrados del portafolio anterior). 5 piezas con
   video/poster real; 8 con portada de marca (image-slot src = portada; el
   usuario puede soltar la portada real encima y se reemplaza). El reel se
   muestra en monocromo en reposo y florece a color al hover (regla 85/15). */
/* RES(): en el archivo standalone (bundle) las rutas locales se sirven desde
   window.__resources (blob URLs inlineados); en el sitio normal cae a la ruta
   relativa original. Los 2 videos remotos (framerusercontent) no se inlinean. */
const RES_MAP = {
  'assets/works/video/altura-divina.mp4': 'wAlturaVid',
  'assets/works/video/salsuz.mp4': 'wSalsuzVid',
  'assets/works/video/la-perla.mp4': 'wPerlaVid',
  'assets/works/poster/altura-divina.png': 'wAlturaPos',
  'assets/works/poster/salsuz.png': 'wSalsuzPos',
  'assets/works/poster/la-perla.png': 'wPerlaPos',
  'assets/works/poster/cerveceria-icono.png': 'wIconoPos',
  'assets/works/poster/parlamento.png': 'wParlamentoPos',
  'assets/works/cover/kings-bred.png': 'wKingsCov',
  'assets/works/cover/dj-dynamiq.png': 'wDjCov',
  'assets/works/cover/bolos.png': 'wBolosCov',
  'assets/works/cover/tseek-cafe.png': 'wTseekCov',
  'assets/works/cover/mexico-comunica.png': 'wMexicoCov',
  'assets/works/cover/xakes.png': 'wXakesCov',
  'assets/works/cover/tacos-las-agujas.png': 'wTacosCov',
  'assets/works/cover/lamine-yamal.png': 'wLamineCov',
};
function RES(p) { if (!p) return p; const id = RES_MAP[p]; if (window.__resources) return window.__resources[id] || ''; return p; }
const WORKS = [
  { id: 'altura-divina',   title: 'Altura Divina',    type: 'tienda + marca',     cat: 'e-commerce', catColor: 'var(--cat-verde)',  year: '2025',
    video: 'assets/works/video/altura-divina.mp4', poster: 'https://framerusercontent.com/images/n7mT1vDCfD1xIC59FNvvFjVsKyk.png', cover: 'https://framerusercontent.com/images/n7mT1vDCfD1xIC59FNvvFjVsKyk.png' },
  { id: 'kings-bred',      title: 'Kings Bred',       type: 'tienda + merch',     cat: 'e-commerce', catColor: 'var(--cat-verde)',  year: '2025',
    cover: 'https://framerusercontent.com/images/9h8ocDXk5ZKko4Rgnc5D0jAdDY.png' },
  { id: 'salsuz',          title: 'Salsuz',           type: 'brand development',  cat: 'brand',      catColor: 'var(--cat-sienna)', year: 'desde 2017',
    video: 'assets/works/video/salsuz.mp4', poster: 'https://framerusercontent.com/images/d70Zr4CoRtj0eYdBCho3z6ljI.png', cover: 'https://framerusercontent.com/images/d70Zr4CoRtj0eYdBCho3z6ljI.png' },
  { id: 'dj-dynamiq',      title: 'DJ Dynamiq',       type: 'web + motion',       cat: 'web',        catColor: 'var(--cat-azure)',  year: '2025',
    cover: 'https://framerusercontent.com/images/fpcJqZd6mtDIVaWoV1wbW3lWb3k.png' },
  { id: 'cerveceria-icono',title: 'Cervecería Ícono', type: 'motion graphics',    cat: 'continuo',   catColor: 'var(--cat-yellow)', year: '2025',
    video: 'https://framerusercontent.com/assets/XlTkRyNd7qzwtOSBwM1VBvZW7I.mp4', poster: 'https://framerusercontent.com/images/tumLQsO29hrTQ3DCA3EFOk5PLnw.png', cover: 'https://framerusercontent.com/images/tumLQsO29hrTQ3DCA3EFOk5PLnw.png' },
  { id: 'mexico-comunica', title: 'México Comunica',  type: 'portal de noticias', cat: 'web',        catColor: 'var(--cat-azure)',  year: '2025',
    cover: 'https://framerusercontent.com/images/LinPPXwMPRXxxQAdAG61vazNFSY.png' },
  { id: 'parlamento',      title: 'Parlamento',       type: 'brand development',  cat: 'brand',      catColor: 'var(--cat-sienna)', year: '2025',
    video: 'https://framerusercontent.com/assets/E8lI4DWQIgqDPywBsOzCHSR8DXM.mp4', poster: 'https://framerusercontent.com/images/q4v1lodgGWoHyh1rKaCoc5hCNII.png', cover: 'https://framerusercontent.com/images/q4v1lodgGWoHyh1rKaCoc5hCNII.png' },
  { id: 'tseek-cafe',      title: 'Tseek Café',       type: 'diseño web',         cat: 'web',        catColor: 'var(--cat-azure)',  year: '2024',
    cover: 'https://framerusercontent.com/images/XenSbZZwS5SLzudpuU60qBeu9Bc.png' },
  { id: 'la-perla',        title: 'La Perla',         type: 'motion · lyric',     cat: 'continuo',   catColor: 'var(--cat-yellow)', year: '2025',
    video: 'assets/works/video/la-perla.mp4', poster: 'https://framerusercontent.com/images/II4hXlsYIMvMPA7CAmKrZo1C4EQ.png', cover: 'https://framerusercontent.com/images/II4hXlsYIMvMPA7CAmKrZo1C4EQ.png' },
  { id: 'bolos',           title: 'Bolos',            type: 'catálogo + marca',   cat: 'web',        catColor: 'var(--cat-azure)',  year: '2025',
    cover: 'https://framerusercontent.com/images/lbWuacQpjfokseCHlBP1n7FCkjc.png' },
  { id: 'tacos-las-agujas',title: 'Tacos Las Agujas', type: 'social media',       cat: 'continuo',   catColor: 'var(--cat-yellow)', year: '2024',
    cover: 'https://framerusercontent.com/images/Am3lRTpFvrFjgngDj8E4N31t2Mo.png' },
  { id: 'xakes',           title: 'Xakes Solutions',  type: 'desarrollo web',     cat: 'web',        catColor: 'var(--cat-azure)',  year: '2024',
    cover: 'https://framerusercontent.com/images/mjrByPXL05H2LpFdgSjXnm1Y6Y.png' },
  { id: 'lamine-yamal',    title: 'Lamine Yamal',     type: 'cover · visual',     cat: 'brand',      catColor: 'var(--cat-sienna)', year: '2025',
    cover: 'https://framerusercontent.com/images/e97QYZEhMLVtgZv7W9DxwmFb1k.png' },
];

function WorkCard({ w }) {
  const vidRef = React.useRef(null);
  const vsrc = RES(w.video);
  const poster = RES(w.poster);
  const cover = RES(w.cover);
  const canPlay = !!w.video && !!vsrc;           // standalone: vsrc='' → poster-only
  const play = () => { const v = vidRef.current; if (v) { const p = v.play(); if (p && p.catch) p.catch(() => {}); } };
  const stop = () => { const v = vidRef.current; if (v) v.pause(); };
  return (
    <article className="work" style={{ '--wcat': w.catColor }}
      onMouseEnter={canPlay ? play : undefined} onMouseLeave={canPlay ? stop : undefined}>
      <a className="work-link" href={'/proyecto/' + w.id} aria-label={w.title}>
      <div className="work-frame">
        {canPlay ? (
          <video ref={vidRef} className="work-media" muted loop playsInline preload="metadata"
            poster={poster} src={vsrc} aria-label={w.title} />
        ) : w.video ? (
          <img className="work-media" src={poster} alt={w.title} loading="lazy" />
        ) : (
          <image-slot id={'work-' + w.id} class="work-media" shape="rect" fit="cover" src={cover} placeholder={w.title}></image-slot>
        )}
        <div className="work-veil">
          <div className="work-top">
            <span className="work-cat"><i className="pip" aria-hidden="true" />{tType(w.type)}</span>
            <span className="work-year">{w.year}</span>
          </div>
          <div className="work-bot">
            <span className="work-title">{w.title}</span>
          </div>
        </div>
      </div>
      </a>
    </article>
  );
}

function Works() {
  const landingWorks = WORKS.slice(0, 6);
  return (
    <section id="works" className="paper-chapter">
      <div className="paper-inner">
        <div className="works-head">
          <div>
            <p className="pc-eyebrow"><i className="pip" aria-hidden="true" />{t('works.eyebrow')}</p>
            <h2 className="pc-title">{t('nav.works')}<span className="dot">.</span></h2>
          </div>
          <span className="works-count">{WORKS.length} {t('works.count')}</span>
        </div>
        <div className="works-grid">
          {landingWorks.map((w) => <WorkCard key={w.id} w={w} />)}
        </div>
        <div style={{ display: 'flex', justifyContent: 'center', marginTop: '3rem' }}>
          <Button intent="neutral" variant="dotted" arrow href="/works">
            {t('works.cta')}
          </Button>
        </div>
      </div>
    </section>
  );
}

/* CATÁLOGO REAL — verdad interna, SÓLO en este step (2E). Nunca en superficies públicas. */
const P = (v) => (v && typeof v === 'object' && !Array.isArray(v)) ? (v[LANG] || v.es) : v;
const fmtMXN = (n) => '$' + n.toLocaleString('en-US');
const CATALOG = {
  web: { key: 'web', label: 'web', color: 'var(--cat-azure)', tagKey: 'mos.tag.web', items: [
    { id: 'landing',    name: 'Landing',      price: 15000, time: { es: '3 semanas',   en: '3 weeks' },   desc: { es: 'Una página, hecha para convertir.', en: 'One page, built to convert.' } },
    { id: 'multi',      name: { es: 'Multi-página', en: 'Multi-page' }, price: 28000, time: { es: '4–5 semanas', en: '4–5 weeks' }, desc: { es: 'Sitio completo de varias secciones.', en: 'A full multi-section site.' } },
    { id: 'plataforma', name: { es: 'Plataforma', en: 'Platform' },     price: 40000, time: { es: '6–8 semanas', en: '6–8 weeks' }, desc: { es: 'A la medida, con lógica propia.', en: 'Custom-built, with its own logic.' } },
  ] },
  ecommerce: { key: 'ecommerce', label: 'e-commerce', color: 'var(--cat-verde)', tagKey: 'mos.tag.ecommerce', items: [
    { id: 'shopify',    name: 'Shopify', price: 22000, time: { es: '4 semanas', en: '4 weeks' }, desc: { es: 'Tienda lista para vender.', en: 'A store ready to sell.' } },
    { id: 'inventario', name: { es: 'Shopify + inventario', en: 'Shopify + inventory' }, price: 25000, time: { es: '4 semanas', en: '4 weeks' }, desc: { es: 'Con control de inventario.', en: 'With inventory control.' } },
  ] },
  brand: { key: 'brand', label: 'brand', color: 'var(--cat-sienna)', tagKey: 'mos.tag.brand', items: [
    { id: 'branding', name: { es: 'Branding completo', en: 'Full branding' }, price: 10000, time: { es: '4 semanas', en: '4 weeks' }, desc: { es: 'Identidad de marca, de punta a punta.', en: 'Brand identity, end to end.' } },
  ] },
};
const CAT_ORDER = ['web', 'ecommerce', 'brand'];
const ADDONS = [
  { id: 'mantenimiento', name: { es: 'Mantenimiento', en: 'Maintenance' }, price: 1500, desc: { es: 'tu sitio siempre al día', en: 'your site always current' } },
  { id: 'redes',         name: { es: 'Redes sociales', en: 'Social media' }, price: 5000, desc: { es: 'gestión mes con mes', en: 'managed monthly' } },
];
const addonById = (id) => ADDONS.find((a) => a.id === id);

/* ── WhatsApp + HOOK DE AGENDA ──────────────────────────────────────────
   WhatsApp: número real de Rubén.
   Agenda: el botón "agenda una llamada" usa BOOK_URL como fallback. Para
   conectar Google Calendar appointment scheduling, Claude Code debe:
     - definir window.rmBookCall  (abre el scheduler), ó
     - reemplazar BOOK_URL con la liga pública del calendario.
   Detalle completo en design_handoff_rmorenodotcom/AGENDA-HANDOFF.md */
const WA_NUMBER = '526861953820';
const BOOK_URL = '#contacto';
function onBook(e) { if (typeof window.rmBookCall === 'function') { e.preventDefault(); window.rmBookCall(); } }

function QuoteStep({ name, cat, scope, addons, onBack }) {
  const c = CATALOG[cat];
  const item = c && c.items.find((i) => i.id === scope);
  if (!c || !item) return null;
  const monthly = addons.reduce((s, id) => s + (addonById(id) ? addonById(id).price : 0), 0);
  const ref = 'RM·' + c.label.slice(0, 3).toUpperCase() + '·26';
  const clean = (name || '').trim();
  const first = clean.split(/\s+/)[0] || '';
  const waText = encodeURIComponent(
    `Hola Rubén, soy ${clean || (LANG === 'en' ? 'a new client' : 'un nuevo cliente')}. ` +
    `${LANG === 'en' ? 'I\u2019m interested in' : 'Me interesa'} ${P(item.name)} (${c.label}) — ${t('mos.from')} ${fmtMXN(item.price)} MXN` +
    (monthly ? ` + ${fmtMXN(monthly)}/mes` : '') + '. ¿Agendamos?'
  );
  const waHref = `https://wa.me/${WA_NUMBER}?text=${waText}`;
  return (
    <div className="qk-ticket">
      <button type="button" className="qk-back" onClick={onBack}>
        {DotIcon ? <DotIcon name="arrow-left" size={13} /> : '←'} {t('mos.back')}
      </button>
      <span className="qk-ticket-tag"><Tag intent="waiting" variant="soft">{t('quote.tag')}</Tag></span>
      <h2 className="qk-ticket-h">{t('quote.headA')}{first || t('quote.va')}{t('quote.headB')}<span className="dot">.</span></h2>
      <p className="qk-ticket-sub">{t('quote.sub')}</p>

      <div className="qk-receipt" style={{ '--qc': c.color }}>
        <div className="qk-receipt-top">
          <span className="qk-receipt-cat"><i className="pip" aria-hidden="true" />{c.label}</span>
          <span className="qk-receipt-ref">{ref}</span>
        </div>
        <div className="qk-line-item">
          <span><span className="qk-li-name">{P(item.name)}</span><span className="qk-li-desc">{P(item.desc)}</span></span>
          <span className="qk-li-amt">{fmtMXN(item.price)}<span className="qk-li-unit">MXN</span></span>
        </div>
        {addons.map((id) => {
          const a = addonById(id); if (!a) return null;
          return (
            <React.Fragment key={id}>
              <hr className="dot-divider" />
              <div className="qk-line-item qk-li--add">
                <span><span className="qk-li-name">+ {P(a.name)}</span><span className="qk-li-desc">{P(a.desc)}</span></span>
                <span className="qk-li-amt">{fmtMXN(a.price)}<span className="qk-li-unit">MXN/{LANG === 'en' ? 'mo' : 'mes'}</span></span>
              </div>
            </React.Fragment>
          );
        })}
        <hr className="dot-divider" />
        <div className="qk-total">
          <div className="qk-total-l">
            <span className="qk-total-label">{t('mos.deliverLbl')}</span>
            <span className="qk-total-time">{P(item.time)}</span>
          </div>
          <div className="qk-total-price">
            <span className="qk-total-from">{t('mos.from')}</span>
            <span className="qk-total-amt">{fmtMXN(item.price)} <span className="qk-cur">MXN</span></span>
            {monthly > 0 && <span className="qk-total-monthly">+ {fmtMXN(monthly)} MXN{t('mos.monthly')}</span>}
          </div>
        </div>
      </div>

      <p className="qk-disclaimer"><i className="qk-dz-dot" aria-hidden="true" /><span>{t('mos.disc')}</span></p>

      <div className="qk-ticket-cta">
        <Button intent="accent" variant="dotted" href={BOOK_URL} onClick={onBook}>{t('mos.agenda')}</Button>
        <Button intent="confirm" variant="solid" href={waHref}>{t('mos.wa')}</Button>
      </div>
    </div>
  );
}

function CatPicker({ value, onChange }) {
  return (
    <div className="qk-pills" role="radiogroup" aria-label={t('lead.cat')}>
      {CAT_ORDER.map((k) => {
        const c = CATALOG[k];
        return (
          <button type="button" key={k} role="radio" aria-checked={value === k}
            className={'qk-pill' + (value === k ? ' on' : '')} style={{ '--c': c.color }} onClick={() => onChange(k)}>
            <i className="qk-pill-dot" aria-hidden="true" />{c.label}
          </button>
        );
      })}
    </div>
  );
}
function ScopeList({ cat, value, onChange }) {
  const c = CATALOG[cat]; if (!c) return null;
  return (
    <div className="qk-scope" role="radiogroup" aria-label={t('mos.scope')} style={{ '--qc': c.color }}>
      {c.items.map((it) => (
        <button type="button" key={it.id} role="radio" aria-checked={value === it.id}
          className={'qk-scope-row' + (value === it.id ? ' on' : '')} onClick={() => onChange(it.id)}>
          <span className="qk-radio" aria-hidden="true" />
          <span><span className="qk-scope-name">{P(it.name)}</span><span className="qk-scope-desc">{P(it.desc)}</span></span>
        </button>
      ))}
    </div>
  );
}
function ContinuoAdd({ value, onToggle }) {
  return (
    <div className="qk-continuo">
      <p className="qk-continuo-opt"><i className="qk-opt-dot" aria-hidden="true" />{t('mos.contOpt')}</p>
      <p className="qk-continuo-q">{t('mos.contQ')}</p>
      <p className="qk-continuo-sub">{t('mos.contSub')}</p>
      <div className="qk-addons">
        {ADDONS.map((a) => {
          const on = value.indexOf(a.id) !== -1;
          return (
            <button type="button" key={a.id} className={'qk-addon' + (on ? ' on' : '')} aria-pressed={on} onClick={() => onToggle(a.id)}>
              <span className="qk-addon-box" aria-hidden="true">{DotIcon ? <DotIcon name="check" size={11} /> : '✓'}</span>
              {P(a.name)}<span className="qk-addon-amt">+{fmtMXN(a.price)}/{LANG === 'en' ? 'mo' : 'mes'}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

function LeadCapture() {
  const [name, setName] = React.useState('');
  const [email, setEmail] = React.useState('');
  const [cat, setCat] = React.useState('');
  const [scope, setScope] = React.useState('');
  const [addons, setAddons] = React.useState([]);
  const [msg, setMsg] = React.useState('');
  const [sent, setSent] = React.useState(false);
  const pickCat = (k) => { setCat(k); setScope(''); };
  const toggleAddon = (id) => setAddons((a) => a.indexOf(id) !== -1 ? a.filter((x) => x !== id) : a.concat(id));
  function submit(e) { e.preventDefault(); if (scope) setSent(true); }
  return (
    <section id="contacto" className="lead" style={{ '--form-accent': 'var(--cat-sienna)' }}>
      <div className="qk-section">
        <div className="qk-most qk-embed">
          {/* relato + atención humana */}
          <div className="qk-most-left">
            <div>
              <p className="qk-eyebrow"><i className="pip" aria-hidden="true" />{t('lead.eyebrow')}</p>
              <h2 className="qk-most-display" style={{ marginTop: '18px' }}>{t('mos.display')}<span className="dot">.</span></h2>
              <p className="qk-most-sub" style={{ marginTop: '22px' }}>{t('mos.sub')}</p>
            </div>
            <div className="qk-human">
              <div className="qk-human-av"><img src="assets/ruben-ai-avatar.png" alt="Rubén" loading="lazy" decoding="async" /></div>
              <div className="qk-human-meta">
                <span className="qk-human-name">{t('mos.human')}</span>
                <span className="qk-human-role"><i className="qk-status-dot" aria-hidden="true" />{t('mos.humanState')}</span>
              </div>
            </div>
            <div className="qk-most-meta">
              <span className="qk-call-line">{t('mos.callA')}<a href={BOOK_URL} onClick={onBook}>{t('mos.callLink')}</a>{t('mos.callB')}</span>
            </div>
          </div>

          {/* panel del mostrador (formulario → ticket) */}
          <div className="qk-most-right">
            <div className="qk-panel">
              {sent ? <QuoteStep name={name} cat={cat} scope={scope} addons={addons} onBack={() => setSent(false)} /> : (
                <form onSubmit={submit}>
                  <div className="qk-block" style={{ marginTop: 0 }}>
                    <div className="qk-field-row">
                      <Input id="lc-name" label={t('lead.name')} value={name} onChange={setName} placeholder={t('lead.namePh')} />
                      <Input id="lc-email" label={t('lead.email')} type="email" value={email} onChange={setEmail} placeholder={t('lead.emailPh')} />
                    </div>
                  </div>
                  <div className="qk-block">
                    <p className="qk-label">{t('lead.cat')}</p>
                    <CatPicker value={cat} onChange={pickCat} />
                  </div>
                  {cat && (
                    <div className="qk-block qk-reveal" key={cat}>
                      <p className="qk-label">{t('mos.scope')}</p>
                      <ScopeList cat={cat} value={scope} onChange={setScope} />
                    </div>
                  )}
                  {scope && (
                    <div className="qk-block qk-reveal"><ContinuoAdd value={addons} onToggle={toggleAddon} /></div>
                  )}
                  <div className="qk-submit-row">
                    <Button intent="accent" variant="dotted" arrow type="submit">{t('mos.submit')}</Button>
                    <span className="qk-submit-note">{t('mos.note')}</span>
                  </div>
                </form>
              )}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

const FOOT_LINKS = [['/web', 'nav.web'], ['/ecommerce', 'nav.ecommerce'], ['/marca', 'nav.brand'], ['/redes', 'nav.continuo'], ['#works', 'nav.works'], ['#contacto', 'nav.contacto']];
function Footer() {
  // Reveal con clipping/offset: el footer se descubre con scroll intencional
  // hacia abajo — su contenido emerge desde un borde recortado y sube a su sitio.
  const innerRef = React.useRef(null);
  React.useEffect(() => {
    const inner = innerRef.current;
    if (!inner) return;
    const footer = inner.closest('.site-footer');
    const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
    if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
      inner.style.transform = 'none'; inner.style.clipPath = 'none'; inner.style.opacity = '1';
      return;
    }
    let raf = 0;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = 0;
        const r = footer.getBoundingClientRect();
        const vh = window.innerHeight;
        // p: 0 cuando el footer apenas asoma por el borde inferior;
        //    1 cuando su borde superior ha subido hasta el ~40% del viewport.
        const p = clamp((vh - r.top) / (vh - vh * 0.62), 0, 1);
        const e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;  // easeInOut
        inner.style.transform = 'translateY(' + ((1 - e) * 34).toFixed(1) + 'px)';
        inner.style.clipPath = 'inset(' + ((1 - e) * 14).toFixed(1) + '% 0% 0% 0%)';
        inner.style.opacity = (0.55 + 0.45 * e).toFixed(3);
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    onScroll();
    return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); };
  }, []);
  return (
    <footer className="site-footer" data-theme="dark">
      <div className="footer-inner" ref={innerRef}>
        <nav className="footer-links" aria-label="Footer">
          {FOOT_LINKS.map(([h, l]) => <a key={h} href={h} className="footer-link">{t(l)}</a>)}
        </nav>
        <h2 className="footer-wm">rmorenodot<i className="footer-dot" aria-hidden="true" />com</h2>
        <div className="footer-bottom">
          <span className="footer-copy">© 2026 rmorenodot.com</span>
          <span className="footer-tag">{t('footer.tag')}<i className="footer-cdot" aria-hidden="true" /></span>
        </div>
      </div>
    </footer>
  );
}

/* ── ContactHUD — consola analógica de "canal directo." Aparece SÓLO al llegar a
   contacto por un CTA/botón (intercepta cualquier ancla #contacto); el scroll
   natural a la sección queda intacto. Reusa el mismo formulario/estimado. ───── */
function ContactHUD() {
  const [open, setOpen] = React.useState(false);
  const [name, setName] = React.useState('');
  const [email, setEmail] = React.useState('');
  const [cat, setCat] = React.useState('');
  const [scope, setScope] = React.useState('');
  const [addons, setAddons] = React.useState([]);
  const [sent, setSent] = React.useState(false);
  const pickCat = (k) => { setCat(k); setScope(''); };
  const toggleAddon = (id) => setAddons((a) => a.indexOf(id) !== -1 ? a.filter((x) => x !== id) : a.concat(id));
  function submit(e) { e.preventDefault(); if (scope) setSent(true); }

  React.useEffect(() => {
    const onDocClick = (e) => {
      const a = e.target && e.target.closest && e.target.closest('a[href="#contacto"]');
      // el CTA del HERO NO abre el HUD: hace scroll suave al form (ver Hero)
      if (a && !a.hasAttribute('data-hero-cta')) { e.preventDefault(); setOpen(true); }   // NO stopPropagation: deja que el dropdown se cierre
    };
    document.addEventListener('click', onDocClick, true);
    const onEvt = () => setOpen(true);
    window.addEventListener('rm-open-contact', onEvt);
    return () => { document.removeEventListener('click', onDocClick, true); window.removeEventListener('rm-open-contact', onEvt); };
  }, []);

  React.useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    window.addEventListener('keydown', onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = prev; };
  }, [open]);

  return (
    <div className={'chud' + (open ? ' on' : '')} role="dialog" aria-modal="true" aria-label={t('mos.display')}
         onMouseDown={(e) => { if (e.target === e.currentTarget) setOpen(false); }}>
      <div className="chud-frame" style={{ '--hud': 'var(--cat-sienna)' }}>
        <span className="chud-br tl" /><span className="chud-br tr" /><span className="chud-br bl" /><span className="chud-br br" />
        <div className="chud-bar">
          <span className="chud-eyebrow chud-eyebrow--bar"><i className="pip" aria-hidden="true" />{t('lead.eyebrow')}</span>
          <span className="chud-bar-r">
            <button type="button" className="chud-x" aria-label={t('hud.close')} onClick={() => setOpen(false)}>
              {DotIcon ? <DotIcon name="close" size={15} /> : <span aria-hidden="true">✕</span>}
            </button>
          </span>
        </div>

        <div className="chud-screen">
          <div className="chud-rail left" aria-hidden="true" />
          <div className="chud-content">
            <h2 className="chud-title">{t('mos.display')}<span className="dot">.</span></h2>
            <p className="chud-sub">{t('mos.sub')}</p>

            <div className="chud-human">
              <div className="chud-human-av"><img src="assets/ruben-ai-avatar.png" alt="Rubén" loading="lazy" decoding="async" /></div>
              <div className="chud-human-meta">
                <span className="chud-human-name">{t('mos.human')}</span>
                <span className="chud-human-role"><i className="chud-status-dot" aria-hidden="true" />{t('mos.humanState')}</span>
              </div>
            </div>
            <p className="chud-callline">{t('mos.callA')}<a href={BOOK_URL} onClick={onBook}>{t('mos.callLink')}</a>{t('mos.callB')}</p>

            <hr className="chud-div" />

            <div className="qk-panel">
              {sent ? <QuoteStep name={name} cat={cat} scope={scope} addons={addons} onBack={() => setSent(false)} /> : (
                <form onSubmit={submit}>
                  <div className="qk-block" style={{ marginTop: 0 }}>
                    <div className="qk-field-row">
                      <Input id="hud-name" label={t('lead.name')} value={name} onChange={setName} placeholder={t('lead.namePh')} />
                      <Input id="hud-email" label={t('lead.email')} type="email" value={email} onChange={setEmail} placeholder={t('lead.emailPh')} />
                    </div>
                  </div>
                  <div className="qk-block">
                    <p className="qk-label">{t('lead.cat')}</p>
                    <CatPicker value={cat} onChange={pickCat} />
                  </div>
                  {cat && (
                    <div className="qk-block qk-reveal" key={cat}>
                      <p className="qk-label">{t('mos.scope')}</p>
                      <ScopeList cat={cat} value={scope} onChange={setScope} />
                    </div>
                  )}
                  {scope && (
                    <div className="qk-block qk-reveal"><ContinuoAdd value={addons} onToggle={toggleAddon} /></div>
                  )}
                  <div className="qk-submit-row">
                    <Button intent="accent" variant="dotted" arrow type="submit">{t('mos.submit')}</Button>
                    <span className="qk-submit-note">{t('mos.note')}</span>
                  </div>
                </form>
              )}
            </div>
          </div>
          <div className="chud-rail right" aria-hidden="true" />
        </div>
      </div>
    </div>
  );
}

/* BackToTop — botón "volver arriba" que aparece SOLO cuando llegas al form por
   el CTA del hero (evento rm-backtop-show). Se oculta al regresar cerca del hero. */
function BackToTop() {
  const [show, setShow] = React.useState(false);
  React.useEffect(() => {
    const onShow = () => setShow(true);
    const onScroll = () => { if (window.scrollY < window.innerHeight * 0.6) setShow(false); };
    window.addEventListener('rm-backtop-show', onShow);
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => { window.removeEventListener('rm-backtop-show', onShow); window.removeEventListener('scroll', onScroll); };
  }, []);
  const toTop = () => {
    if (window.rmWarpScroll) window.rmWarpScroll(0);
    else window.scrollTo({ top: 0, behavior: 'smooth' });
    setShow(false);
  };
  return (
    <button type="button" className={'backtop' + (show ? ' on' : '')} onClick={toTop}
            aria-label={t('backtop.label')} aria-hidden={!show} tabIndex={show ? 0 : -1}>
      {DotIcon ? <DotIcon name="arrow-up" size={15} /> : <span aria-hidden="true">↑</span>}
      <span>{t('backtop.label')}</span>
    </button>
  );
}

/* ScrollHintOverlay — aparece UNA vez por sesión cuando la animación del hero
   asienta (evento rm-hero-settled, disparado al final de commitFinal en
   index.html). Hermano calmado del HUD de contacto: mismo backdrop, brackets,
   instrucción seca con punto verde (--cat-verde) y la señal de scroll de la
   casa (keyframe scrollDot). Se cierra con la X de la esquina, al empezar a
   scrollear, al tocar el backdrop o Escape. */
var SHINT_SEEN_KEY = 'rm_scroll_hint_overlay_seen';
function ScrollHintOverlay() {
  const [open, setOpen] = React.useState(false);
  const close = React.useCallback(() => {
    setOpen(false);
    try { sessionStorage.setItem(SHINT_SEEN_KEY, '1'); } catch (e) {}
  }, []);

  React.useEffect(() => {
    let seen = false;
    try { seen = sessionStorage.getItem(SHINT_SEEN_KEY) === '1'; } catch (e) {}
    if (seen) return;
    const onSettled = () => {
      try { if (sessionStorage.getItem(SHINT_SEEN_KEY) === '1') return; } catch (e) {}
      setOpen(true);
    };
    window.addEventListener('rm-hero-settled', onSettled);
    return () => window.removeEventListener('rm-hero-settled', onSettled);
  }, []);

  React.useEffect(() => {
    if (!open) return;
    // dismiss al empezar a scrollear (cualquier gesto de scroll/wheel/touch)
    const onScroll = () => close();
    const onKey = (e) => { if (e.key === 'Escape') close(); };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('wheel', onScroll, { passive: true });
    window.addEventListener('touchmove', onScroll, { passive: true });
    window.addEventListener('keydown', onKey);
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('wheel', onScroll);
      window.removeEventListener('touchmove', onScroll);
      window.removeEventListener('keydown', onKey);
    };
  }, [open, close]);

  return (
    <div className={'shint' + (open ? ' on' : '')} role="dialog" aria-modal="true"
         aria-label={t('shint.title')} aria-hidden={!open}
         onMouseDown={(e) => { if (e.target === e.currentTarget) close(); }}>
      <div className="shint-frame">
        <span className="shint-br tl" /><span className="shint-br tr" />
        <span className="shint-br bl" /><span className="shint-br br" />
        <button type="button" className="shint-close" onClick={close} aria-label={t('shint.close')} title={t('shint.close')}>
          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
            <path d="M3.5 3.5 L12.5 12.5 M12.5 3.5 L3.5 12.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
          </svg>
        </button>
        <h2 className="shint-title">{t('shint.title')}<span className="dot">.</span></h2>
        <hr className="shint-div" />
        <div className="shint-cue" aria-hidden="true"><i /></div>
        <p className="shint-cue-label">{t('shint.cue')}</p>
      </div>
    </div>
  );
}

function PageApp() {
  return (
    <React.Fragment>
      <NavBar />
      <Hero />
      <Manifiesto />
      <MapaEstelar />
      <ComoTrabajamos />
      <Works />
      <LeadCapture />
      <Footer />
      <ContactHUD />
      <ScrollHintOverlay />
      <BackToTop />
    </React.Fragment>
  );
}

const __rmRoot = ReactDOM.createRoot(document.getElementById('page'));
window.rmRenderPage = function () { __rmRoot.render(<PageApp />); };
window.rmRenderPage();

// Llegar a "servicios" desde OTRA página: el link apunta a Portal de entrada.html#mapa.
// Al cargar con ese hash reproducimos la MISMA transición (warp → servicio 01),
// esperando a que el portal se haya entrado y el mapa esté maquetado.
(function () {
  var hash = location.hash;
  var SCROLL_IDS = ['#manifiesto', '#works', '#contacto'];
  if (hash !== '#mapa' && SCROLL_IDS.indexOf(hash) === -1) return;
  try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
  let tries = 0;
  const tryGo = () => {
    const entered = document.body.classList.contains('entered');
    if (hash === '#mapa') {
      const wrap = document.getElementById('mapa');
      const ready = wrap && wrap.offsetHeight > window.innerHeight * 1.2;
      if (entered && ready) { goToServiciosIntro(); return; }
    } else {
      const el = document.getElementById(hash.slice(1));
      if (entered && el && el.offsetHeight > 0) {
        window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY, behavior: 'smooth' });
        return;
      }
    }
    if (tries++ < 360) requestAnimationFrame(tryGo);
  };
  requestAnimationFrame(tryGo);
})();
