/* ==========================================================================
   OBSCURA · SECCIONES
   Orden: 00 nav · 01 hero · 02 clientes+números · 03 qué solucionamos ·
   04 proyectos · 05 muestras · 06 servicios · 07 testimonios ·
   08 filosofía+fases · 09 formulario · 10 QA · footer
   ========================================================================== */

/* ==========================================================================
   00 · NAV — ISLA FLOTANTE. main.js le pone data-theme según la sección de
   abajo, así la tinta se invierte sola al cruzar de crema a negro.

   TRES CAPAS, cada una con un trabajo, y no se pueden fusionar:
     .nav        caja fija a sangre. SIN fondo y SIN blur a propósito: un
                 backdrop-filter acá crearía bloque contenedor y el hairline de
                 progreso dejaría de poder anclarse al borde del viewport.
     .nav__shell lo ÚNICO que morphea (max-width, radio, vidrio, translateY).
     .nav__inner alto fijo — NO TOCAR: .hero__inner tiene 76px y 64px clavados
                 en su padding-top (más abajo, § 01 HERO).
   ========================================================================== */
.nav {
  position: fixed; inset: 0 0 auto 0;
  z-index: var(--z-nav);
  background: transparent;          /* anula el fondo de [data-theme] */
  padding-inline: 0;
  /* Sin esto, una franja de 88px de alto y TODO el ancho se come el hover y los
     clicks del contenido a los costados de la isla. */
  pointer-events: none;
  /* El color va acá y no en cada hijo: las propiedades heredadas transicionan
     en el padre y los descendientes re-heredan el valor animado cuadro a
     cuadro. Un solo declarante para el volteo de tema de todo el header. */
  transition:
    padding var(--dur-slow) var(--ease-expo),
    color   var(--dur-slow) var(--ease-out);
}
/* Al despegarse, el aire lateral lo pone el PADRE. Va acá y no como min() en el
   max-width del shell para que abajo de 1080px la isla igual se separe de los
   bordes en vez de quedar a sangre. */
.nav.is-scrolled { padding-inline: var(--gutter); }

/* --- El shell: lo único que morphea ------------------------------------- */
.nav__shell {
  position: relative;
  pointer-events: auto;
  width: 100%; max-width: 100%;
  margin-inline: auto;
  border-radius: 0;
  background: transparent;
  transform: translateY(0);
  /* NUNCA overflow: hidden acá. Recortaría el anillo de foco de logo, links y
     CTA contra el canto de la cápsula (:focus-visible sale con outline-offset:
     3px, o sea FUERA de la caja). Por eso el canto se dibuja con la máscara de
     abajo y no se reusa la clase .glass, que sí trae overflow: hidden. */
  transition:
    max-width     var(--dur-slow) var(--ease-expo),
    border-radius var(--dur-slow) var(--ease-expo),
    transform     var(--dur-slow) var(--ease-expo),
    background    var(--dur-slow) var(--ease-out),
    box-shadow    var(--dur-slow) var(--ease-out),
    -webkit-backdrop-filter var(--dur-slow) var(--ease-out),
            backdrop-filter var(--dur-slow) var(--ease-out);
}
.nav.is-scrolled .nav__shell {
  max-width: 1080px;
  border-radius: var(--radius-pill);
  transform: translateY(12px);      /* transform y no top: compositor puro */
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
          backdrop-filter: blur(14px) saturate(1.4);
  box-shadow: inset 0 1px 1px var(--glass-inner), var(--shadow-card);
}

/* El canto de vidrio, misma técnica que .glass::before (components.css § 1):
   brillante arriba y abajo, invisible al medio. Con mask-composite en vez de
   overflow, así el anillo de foco no se recorta.

   Lleva la capa especular del glimpse (--glass-spec, 16ª pasada) ENCIMA del
   linear, no en su lugar. Acá el motivo aditivo es más fuerte que en ninguna
   otra superficie: la isla mide 1080×76 (14:1) y vive contra el borde
   superior, así que el cursor está casi siempre por debajo — un cónico puro
   dejaría este canto de arriba apagado todo el tiempo.
   NO lleva --glass-press en los porcentajes a propósito: la isla no se
   aprieta, y :active propagaría desde su propio CTA. Ver components.css § 1. */
/* El aro va por box-shadow y NO por degradado recortado con mask-composite.
   El porqué está entero en components.css § 1: restar dos máscaras separadas
   1,2px puntea la línea sobre la CURVA. Acá era donde peor se veía —el radio
   es de cápsula, o sea curva continua de punta a punta— y es lo que se leía
   como "el borde del header está pixelado".
   El degradado no se pierde: pasa a ser la máscara. Trazo al alfa máximo
   (.50) y rampa relativa 1 / .32 / 0 = los .50 / .16 / 0 de siempre, en los
   mismos 22 / 42 / 58 / 78. Sin --glass-press en los porcentajes, igual que
   antes: la isla no se aprieta (ver components.css § 1). */
.nav__shell::before {
  content: "";
  position: absolute; inset: 0; z-index: 2;
  border-radius: inherit;
  box-shadow: inset 0 0 0 var(--hairline) rgba(var(--glass-rgb), .50);
  -webkit-mask: linear-gradient(
      180deg,
      #000               0%,
      rgba(0, 0, 0, .32) 22%,
      transparent        42%,
      transparent        58%,
      rgba(0, 0, 0, .32) 78%,
      #000              100%
    );
          mask: linear-gradient(
      180deg,
      #000               0%,
      rgba(0, 0, 0, .32) 22%,
      transparent        42%,
      transparent        58%,
      rgba(0, 0, 0, .32) 78%,
      #000              100%
    );
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease-out);
}
/* El especular del glimpse vive en ::after (components.css § 1 bis), que lo
   crea SIN opacidad propia. Sin esto la isla mostraría el arco de luz flotando
   sola antes de scrollear, con el canto todavía apagado. */
.nav__shell::after {
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease-out);
}
.nav.is-scrolled .nav__shell::before,
.nav.is-scrolled .nav__shell::after { opacity: 1; }

/* --- El inner: alto fijo, sólo respira de padding ------------------------ */
.nav__inner {
  position: relative;
  height: 76px;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--gutter);
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-6);
  transition: padding var(--dur-slow) var(--ease-expo);
}
/* El "más padding" de la cápsula. Es lo único que se mueve adentro. */
.nav.is-scrolled .nav__inner { padding-inline: var(--sp-8); }

/* --- Logotipo ------------------------------------------------------------ */
.nav__logo { display: flex; align-items: center; }
/* SVG por <use> contra el sprite del <body>. currentColor hace el volteo de
   tema: se fue el PNG de 43 KB y su filter: brightness(0).
   aspect-ratio explícito y no width:auto solo, para no depender del dimensionado
   intrínseco del viewBox.

   30px Y NO 26, Y ES UN ARREGLO DE NITIDEZ, NO UN CAPRICHO DE ESCALA.
   El wordmark es vectorial y está impecable; lo que fallaba era la cantidad de
   píxeles. A 26px de alto, los trazos finos de la floritura de la «O» miden
   MENOS DE UN PÍXEL a DPR 1, así que el antialias los reparte en grises y la
   floritura se lee como una mancha en vez de como líneas. Comparadas las
   cuatro alturas a 11× sobre monitor 1x, 30px es donde los trazos vuelven a
   separarse. 30 × (1235/266) = 139,3px de ancho (eran 120,7).

   En móvil se queda en 22px, más abajo: no hace falta y además no conviene.
   Los teléfonos son DPR 2 o más —ahí 22px ya son 44+ físicos y la floritura se
   lee perfecta— y el presupuesto de ancho del header a 390px está contado al
   píxel (ver el cálculo en el @media de 640px). */
.nav__mark {
  height: 30px; width: auto;
  aspect-ratio: 1235 / 266;
}

/* --- Links y la píldora de sección activa -------------------------------- */
.nav__links {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  display: flex; gap: var(--sp-4);
}
.nav__links a {
  position: relative; z-index: 1;
  font-family: var(--font-ui); font-weight: 600; font-size: var(--fs-sm);
  color: var(--ink-mute);           /* --ink-soft NO: el vidrio levanta el suelo */
  white-space: nowrap;              /* es lo que impide que el morph re-mida texto */
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--radius-pill);
  transition: color var(--dur) var(--ease-out);
}
.nav__links a:hover,
.nav__links a[aria-current] { color: var(--ink); }

/* Subrayado que se dibuja en hover. Va inseteado el padding nuevo: con left y
   right en 0 quedaría más ancho que la palabra. */
.nav__links a::after {
  content: ""; position: absolute;
  left: var(--sp-3); right: var(--sp-3); bottom: 4px;
  height: 1px; background: var(--accent);
  transform: scaleX(0);
  transition: transform var(--dur) var(--ease-out);
}
.nav__links a:hover::after { transform: scaleX(1); }

/* UN solo elemento que se desliza. Su geometría es INVARIANTE al morph: los
   offsets se miden dentro de .nav__links, cuyo ancho es del contenido, así que
   pasar el shell de 100% a 1080px no los mueve un píxel. Por eso no hay que
   re-medir durante los 640 ms. */
.nav__pill {
  position: absolute; inset-block: 0; left: 0; z-index: 0;
  width: var(--pill-w, 0px);
  transform: translate3d(var(--pill-x, 0px), 0, 0);
  border-radius: var(--radius-pill);
  background: rgba(var(--glass-rgb), .10);
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--dur) var(--ease-expo),
    width     var(--dur) var(--ease-expo),
    opacity   var(--dur) var(--ease-out);
}
.nav__links.has-active .nav__pill { opacity: 1; }
/* La primera aparición no puede deslizarse desde x=0: el módulo 3 pone esta
   clase, escribe la posición, fuerza un reflow y recién ahí la saca. */
.nav__links.is-jumping .nav__pill { transition: opacity var(--dur) var(--ease-out); }

/* --- Mini menú de "Servicios" -------------------------------------------
   Las cuatro fichas de servicio sólo se alcanzaban desde adentro de
   /servicios/: ni el nav ni el pie las nombran. Esto las pone a un hover.

   POR QUÉ ENVOLTORIO Y NO UN PANEL SUELTO EN .nav__inner: .nav__links está
   centrada en absoluto y su ancho es el del CONTENIDO, que cambia de página
   en página ("Dónde se pierde" en las nuevas, "Qué solucionamos" en las
   fichas de proyecto). Colgado del propio link, el panel cae bajo la palabra
   en las dieciocho rutas sin medir nada en JS.

   ⚠ NO TOCA LA GEOMETRÍA DE LA PÍLDORA. El módulo 3 mide offsetLeft de los
   links de HASH (a[href^="#"]) y ésos siguen siendo hijos directos de
   .nav__links; "Servicios" nunca estuvo en esa lista porque nunca fue un
   ancla. El envoltorio es position:relative y sólo cambia el offsetParent de
   lo que tiene adentro.

   Abre con :hover Y con :focus-within, o sea que funciona entero sin JS: con
   teclado, el Tab que entra al primer sub-link ya lo hace visible. El módulo
   17 agrega SÓLO el Escape, que es lo único que el CSS no puede expresar.
   -------------------------------------------------------------------------- */
.nav__item { position: relative; display: flex; align-items: center; }

/* ⚠ EL CARET VA ABSOLUTO, Y ES POR EL PRESUPUESTO DE ANCHO DEL HEADER.
   En flujo medía 13px (6 de escuadra + 7 de margen) y ensanchaba la fila
   entera; como .nav__links está centrada, la mitad de eso se va contra el CTA.
   MEDIDO a 1025px —el primer ancho donde los links todavía existen, uno más
   que el @media de 1024— la holgura contra .nav__actions pasaba de +4px a
   −2px: el link "Preguntas" se metía DEBAJO del botón. Absoluto, dentro del
   padding-right de sp-3 que el link ya tenía, la fila mide exactamente lo
   mismo que antes de este menú y el subrayado de hover (::after, de sp-3 a
   sp-3) sigue cayendo justo debajo de la palabra. */
.nav__caret {
  position: absolute;
  right: 0; top: 50%;
  width: 5.5px; height: 5.5px;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  opacity: .65;
  /* 45deg = punta abajo. Al abrir gira media vuelta y queda apuntando al link
     del que cuelga el panel. El -70% en Y compensa la caja rotada, que crece
     hacia abajo desde el centro. */
  transform: translateY(-70%) rotate(45deg);
  transition: transform var(--dur) var(--ease-out), opacity var(--dur) var(--ease-out);
}
.nav__item--menu:hover .nav__caret,
.nav__item--menu:focus-within .nav__caret {
  opacity: 1;
  transform: translateY(-30%) rotate(225deg);
}

.nav__sub {
  position: absolute; z-index: 3;   /* 2 es el canto del shell (::before) */
  top: 100%; left: 50%;
  width: 18rem;
  /* Cuelga del LINK, pero tiene que despegarse de la ISLA. Del canto de abajo
     del link al canto de abajo del shell hay 19px EXACTOS, y son los mismos en
     los dos estados: sin scroll el shell va de 0 a 76 y el link termina en 57;
     pegado, el translateY(12) lo lleva de 12 a 88 y el link termina en 69.
     19 + sp-2 deja el panel 8px por debajo de la cápsula en los dos. */
  margin-top: calc(19px + var(--sp-2));
  padding: var(--sp-2);
  border-radius: var(--radius-lg);
  /* CASI OPACO, y no el 82% de la isla. El panel se abre sobre lo que haya
     debajo —hero con video, muro de plancha, capturas de proyecto—, así que
     su suelo real es 96% --bg + 4% de esa foto. Ese 4% es lo que se calculó
     para elegir el color del renglón chico: ver .nav__sub-micro. */
  background: color-mix(in srgb, var(--bg) 96%, transparent);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
          backdrop-filter: blur(14px) saturate(1.4);
  box-shadow:
    inset 0 0 0 var(--hairline) rgba(var(--glass-rgb), .30),
    var(--shadow-card);
  opacity: 0;
  transform: translate(-50%, -6px);
  pointer-events: none;
  transition:
    opacity   var(--dur) var(--ease-out),
    transform var(--dur) var(--ease-expo);
}
/* Sin visibility:hidden A PROPÓSITO: los sub-links tienen que seguir en el
   orden de tabulación. El Tab que entra en ellos dispara :focus-within y el
   panel aparece solo — que es exactamente el comportamiento que se quiere. */
.nav__item--menu:hover .nav__sub,
.nav__item--menu:focus-within .nav__sub {
  opacity: 1;
  transform: translate(-50%, 0);
  pointer-events: auto;
}

/* Puente de hover. Entre el link y el panel hay 27px de aire y el puntero los
   cruza POR FUERA de .nav__item: sin esta franja transparente el panel se
   cierra a mitad de camino. Va adentro del panel, que es descendiente del
   envoltorio, así que hoverla mantiene vivo el :hover del item.
   Cubre el margen entero + 2px de solape, y sólo existe con el panel abierto
   (cerrado hereda el pointer-events: none del panel). */
.nav__sub::before {
  content: "";
  position: absolute; left: 0; right: 0;
  top: calc(-19px - var(--sp-2) - 2px);
  height: calc(19px + var(--sp-2) + 2px);
}

/* Pisa lo que .nav__links a le impone a CUALQUIER <a> de adentro: inline,
   padding de píldora y el subrayado de acento. */
.nav__sub a {
  display: block;
  padding: var(--sp-3);
  border-radius: var(--radius);
  color: var(--ink-mute);
  transition:
    background var(--dur-fast) var(--ease-out),
    color      var(--dur-fast) var(--ease-out);
}
.nav__sub a::after { content: none; }
.nav__sub a:hover { background: var(--glass-bg-hover); }

.nav__sub-name {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-3);
  font-size: var(--fs-sm); font-weight: 600;
  color: var(--ink);
}
/* --ink-mute Y NO --ink-soft, que es la misma lección que ya está escrita
   veinte líneas más arriba para los links del nav ("el vidrio levanta el
   suelo"). CALCULADO sobre el peor caso del panel —el 4% que se cuela es lo
   que más acerca el fondo al texto—: --ink-soft daba 4,42:1 en tema oscuro,
   o sea POR DEBAJO de AA para texto chico. Con --ink-mute son 6,73:1 en
   oscuro y 5,86:1 en claro. La jerarquía contra el nombre la siguen haciendo
   el cuerpo (13 vs 15px) y el peso (400 vs 600).

   white-space: normal porque .nav__links a lo trae en nowrap — ahí es lo que
   impide que el morph de la isla re-mida el texto, acá partiría el panel. */
.nav__sub-micro {
  display: block;
  margin-top: 3px;
  font-size: var(--fs-xs); font-weight: 400;
  line-height: 1.35;
  color: var(--ink-mute);
  white-space: normal;
}
.nav__sub-arrow {
  flex-shrink: 0;
  color: var(--accent);
  opacity: 0;
  transform: translateX(-4px);
  transition:
    opacity   var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out);
}
.nav__sub a:hover .nav__sub-arrow,
.nav__sub a:focus-visible .nav__sub-arrow { opacity: 1; transform: none; }

/* La ficha en la que ya estás. aria-current="page" lo lleva el sub-link
   exacto; el link "Servicios" de arriba pasa a aria-current="true" (la
   sección, no la página), que el selector de presencia de más arriba sigue
   pintando igual. */
.nav__sub a[aria-current] { background: var(--glass-bg-hover); }
.nav__sub a[aria-current] .nav__sub-arrow { opacity: 1; transform: none; }

/* Cerrado a mano con Escape (módulo 17). Dura hasta que el puntero se va o el
   foco sale del envoltorio, y por eso pisa a las dos reglas de apertura. */
.nav__item--menu.is-dismissed .nav__sub {
  opacity: 0;
  transform: translate(-50%, -6px);
  pointer-events: none;
}
.nav__item--menu.is-dismissed .nav__caret {
  opacity: .65;
  transform: translateY(-70%) rotate(45deg);
}

/* --- Cluster derecho ----------------------------------------------------- */
.nav__actions { display: flex; align-items: center; gap: var(--sp-3); }
.nav__cta { min-height: 44px; padding: .75rem 1.35rem; font-size: var(--fs-sm); }

/* --- Burger -------------------------------------------------------------- */
.nav__burger {
  display: none; flex-direction: column; justify-content: center; gap: 5px;
  width: 44px; height: 44px; align-items: flex-end;   /* objetivo táctil 44x44 */
  padding: 0; margin-right: -10px;
}
.nav__burger span {
  display: block; width: 20px; height: 1.5px; background: var(--ink);
  transform-origin: center;
  transition: transform var(--dur) var(--ease-expo), opacity var(--dur) var(--ease-expo);
}
/* Barras de 1,5px con gap de 5 → 6,5px EXACTOS entre centros (1,5/2 + 5 + 1,5/2).
   Las dos externas convergen ahí y rotan; la del medio escala a cero. */
.nav__burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6.5px)  rotate(45deg); }
.nav__burger[aria-expanded="true"] span:nth-child(2) { transform: scaleX(0); opacity: 0; }
.nav__burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }

/* --- Hairline de progreso, al borde superior del VIEWPORT ----------------
   Es hijo de .nav y no position:fixed a propósito: backdrop-filter crea bloque
   contenedor para descendientes fijos, así que un fixed adentro del header
   quedaría anclado al shell con vidrio en vez de a la ventana. Como .nav es
   fijo, a sangre y sin blur, un absolute en top:0 ES el borde del viewport.
   El padding-inline de .nav no lo insetea: el bloque contenedor de un absoluto
   es la caja de RELLENO del ancestro, no la de contenido. */
.nav__progress {
  position: absolute; left: 0; right: 0; top: 0;
  height: 1.5px; background: var(--accent);
  transform: scaleX(0); transform-origin: left center;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur) var(--ease-out);
}
.nav.is-scrolled .nav__progress { opacity: 1; }

/* --- Con el menú móvil abierto ------------------------------------------
   El nav sube POR ENCIMA del overlay: es lo que hace que la X del burger exista
   de verdad y se pueda tocar. El menú ya pinta --bg, así que el vidrio sobra. */
.nav.is-menu-open { z-index: calc(var(--z-menu) + 1); }
.nav.is-menu-open .nav__shell {
  background: transparent; box-shadow: none;
  -webkit-backdrop-filter: none; backdrop-filter: none;
}
.nav.is-menu-open .nav__shell::before,
.nav.is-menu-open .nav__shell::after,
.nav.is-menu-open .nav__progress { opacity: 0; }

/* --- Arranque ------------------------------------------------------------
   Con la página restaurada a mitad de scroll (F5), el primer update() dispararía
   el morph entero. El módulo 3 saca esta clase después del primer rAF. */
.nav--boot,
.nav--boot .nav__shell,
.nav--boot .nav__shell::before,
.nav--boot .nav__shell::after,
.nav--boot .nav__inner,
.nav--boot .nav__progress,
.nav--boot .nav__pill { transition: none; }

/* ⚠ BUG ENCONTRADO EN LA 18ª, PREEXISTENTE. Los links se ocultaban acá (1024)
   pero el burger no aparecía hasta 640, así que entre 641 y 1024 px la barra
   quedaba SIN NINGUNA NAVEGACIÓN: ni links ni burger, sólo el logo y el CTA.
   Son 384 px de banda muerta, y ahí adentro caen el iPad en vertical (768), el
   iPad Air (820) y cualquier ventana de escritorio achicada.

   El burger tiene que aparecer EXACTAMENTE donde desaparecen los links, así que
   los dos van en la misma media query y no se pueden volver a separar.
   Verificado a 1100 / 1024 / 900 / 768 / 700 / 641 / 640 / 390.
   El resto del bloque de 640 (alto de la isla, logo a 22px) se queda allá: eso
   sí es cosa del ancho de teléfono. */
@media (max-width: 1024px) {
  .nav__links  { display: none; }
  .nav__burger { display: flex; }
}
@media (max-width: 640px) {
  .nav__inner { height: 64px; }
  .nav.is-scrolled .nav__inner { padding-inline: var(--sp-5); }
  .nav__mark { height: 22px; }
  /* El CTA ya NO se esconde. En una landing de generación de leads, dejar el
     header móvil sin CTA y el burger como única puerta es una fuga de
     conversión, no una decisión de layout. Compacto, pero conserva los 44px
     táctiles: bajarlos a 40 obliga a devolver el área con un ::after, y
     .btn--glass tiene overflow: hidden, que recortaría también esa área.
     Cuentas a 390px: 20 + 102 (logo) + 24 + ~101 (CTA) + 12 + 34 (burger) + 20
     = 313 de 390. Entra con aire hasta 360px. */
  .nav__cta {
    font-size: var(--fs-xs);
    padding: .55rem 1.1rem;
    min-height: 44px;
    /* ⚠ ACÁ EL CTA NO SE PARTE, aunque el resto de los .btn sí lo haga bajo 480
       (components.css § 2). Es la excepción y tiene su motivo: ese cambio existe
       para que una etiqueta larga no se salga de la ventana, y este botón no
       tiene ese problema —mide 129px y el presupuesto del header le da lugar de
       sobra—; lo que sí tiene es un ALTO FIJO al lado. Medido a 320: sin esta
       línea el label se partía en dos renglones y la píldora pasaba de 44 a
       50px de alto dentro de un .nav__inner de 64. No se recortaba, pero una
       píldora de dos renglones en la barra se lee como un error.
       Y si alguna vez el CTA cambia a un texto que de verdad no entre, lo que
       hay que achicar es el texto, no dejarlo partir: el presupuesto de esta
       barra está contado al píxel arriba. */
    white-space: nowrap;
  }
}

/* --- Menú móvil ---------------------------------------------------------
   Sin cabecera propia: el nav se dibuja ENCIMA y aporta el logotipo y la X.
   Con cabecera quedaban dos logotipos apilados en la misma posición. */
.mobile-menu {
  position: fixed; inset: 0;
  z-index: var(--z-menu);
  background: var(--bg);
  display: none; flex-direction: column;
  padding-top: 64px;                /* el alto del .nav__inner en móvil */
  padding-bottom: var(--sp-10);
}
.mobile-menu.is-open { display: flex; animation: obs-fade var(--dur) var(--ease-out) both; }
.mobile-menu__links {
  margin: var(--sp-10) var(--gutter) 0;
  display: flex; flex-direction: column; gap: var(--sp-6);
}
.mobile-menu__links a {
  font-family: var(--font-ui); font-weight: 700;
  font-size: clamp(1.75rem, 1.2rem + 2.4vw, 2.25rem);
  color: var(--ink);
}
/* Las cuatro fichas de servicio, también en táctil: bajo 1024px el nav se
   esconde entero y con él su mini menú, así que sin esto el teléfono se queda
   sin la única puerta a /servicios/<ficha>/.
   El margen negativo es contra el gap de sp-6 de la columna: pegadas al link
   del que dependen (sp-4) leen como sublista; a 24px leían como cinco ítems
   más del mismo nivel. */
.mobile-menu__sub {
  display: flex; flex-direction: column; gap: var(--sp-4);
  margin-top: calc(var(--sp-4) - var(--sp-6));
  padding-left: var(--sp-5);
  border-left: 1px solid var(--line);
}
.mobile-menu__sub a {
  font-size: 1.0625rem; font-weight: 600;
  color: var(--ink-mute);
}
.mobile-menu__sub a[aria-current] { color: var(--accent); }
.mobile-menu__ctas {
  margin: auto var(--gutter) 0;
  display: flex; flex-direction: column; gap: var(--sp-3);
  padding-top: var(--sp-10);
}

/* ==========================================================================
   01 · HERO ██
   ========================================================================== */
.hero {
  position: relative;
  min-height: 100svh;
  display: flex; align-items: center;
  overflow: hidden;
  background: var(--bg-sunk);
}

.hero__media {
  position: absolute; inset: 0; z-index: 0;
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0);
  transition: transform 600ms var(--ease-out);
  will-change: transform;
}
.hero__bg {
  position: absolute; inset: -14px;
  display: block;                 /* <picture> es inline por defecto */
  transform-origin: center;       /* lo usa el blur-to-focus del frente vii */
  /* El inset negativo es el sobrebarrido que le da lugar al parallax del modulo 2:
     initHeroParallax mueve #heroMedia como mucho ±12px en X y ±8px en Y (son
     constantes fijas en el cálculo, no un porcentaje del viewport), así que
     14px de margen alcanza sobra en cualquier ancho. Antes era -3%, que a
     1920px son ~58px por lado — mucho más recorte de imagen del que el
     parallax necesita, y se notaba como un zoom de más. */
}
/* object-fit/position reproducen exacto el background-size/position que habia antes. */
.hero__bg picture, .hero__bg img {
  display: block;                 /* <picture> tambien: es inline por defecto */
  width: 100%; height: 100%;
  object-fit: cover; object-position: center;
}
/* NO HAY VELO. La 16ª pasada puso un .hero__veil (elipse + degradé al .70)
   para forzar AA sobre la nube clara detrás del texto; el dueño del sitio no
   lo pidió — la foto va a color completo a propósito — y lo rechazó al verlo
   oscurecido. El contraste del texto se apoya solo en text-shadow (ver
   .hero__title, .hero__subhead más abajo). Con eso, algunos elementos no
   llegan a 4,5:1 en ciertos anchos — es la decisión consciente, no un olvido:
   no reponer el velo sin que lo pida el dueño del sitio. */

.hero__glow {
  position: absolute; z-index: 0;
  width: 60vw; height: 60vw; max-width: 720px; max-height: 720px;
  border-radius: 50%;
  background: var(--accent);
  opacity: .07;
  filter: blur(120px);
  pointer-events: none;
}
.hero__glow--a { top: -12%; left: -10%; animation: obs-drift-a 22s var(--ease-inout) infinite; }
.hero__glow--b { bottom: -16%; right: -10%; animation: obs-drift-b 18s var(--ease-inout) infinite; }
/* Se pausan cuando el hero sale de pantalla. Eran la animación más cara del
   sitio corriendo TODA la vida de la página: dos capas de hasta 720×720 con
   filter: blur(120px), que la GPU tiene que mantener compuestas aunque estén
   diez pantallas más arriba. La clase la pone el módulo 2 con un
   IntersectionObserver; animation-play-state no reinicia nada, así que al
   volver arriba la deriva sigue donde estaba. */
.hero.is-fuera .hero__glow { animation-play-state: paused; }
@keyframes obs-drift-a { 0%,100% { transform: translate3d(0,0,0); } 50% { transform: translate3d(6%,4%,0); } }
@keyframes obs-drift-b { 0%,100% { transform: translate3d(0,0,0); } 50% { transform: translate3d(-5%,-6%,0); } }

.hero__inner {
  position: relative; z-index: 2;
  width: 100%; max-width: var(--container);
  margin-inline: auto;
  /* El bloque sigue centrado por .hero (align-items:center) — lo que lo sube
     es este padding asimétrico: el tope ya está en el piso de 76px clavados
     del nav (ver la nota de nav__inner, no se puede bajar más) y todo el
     empuje hacia arriba sale de agrandar el de abajo. 76/224 deja el bloque
     74px arriba del centro real del viewport.
     Esto vale SÓLO de 1025px para arriba: los dos @media del final de la
     sección lo pisan con una subida más moderada (100/120 y 88/112). El
     escritorio se queda acá a propósito, no por olvido. */
  padding: 76px var(--gutter) calc(var(--sp-24) + var(--sp-10) + var(--sp-14) + var(--sp-20) + var(--sp-6));
  display: flex; flex-direction: column; align-items: center;
  text-align: center;
  gap: var(--sp-6);
}

.hero__title {
  color: var(--ink);
  /* 9.2em ES el 20ch de Instrument Serif, medido (su avance del "0" es .46em).
     Bajo la fuente real el renderizado es idéntico al de antes.

     ⚠ POR QUÉ NO PUEDE IR EN `ch`, que es contraintuitivo: `ch` se mide contra
     la fuente EN USO, así que durante el swap la CAJA cambia de ancho junto
     con el texto. Georgia crudo zafaba de casualidad —escala texto y caja en
     la misma proporción y wrappea en los mismos 3 renglones—, pero el
     @font-face de métricas ajustadas (base.css) rompe esa proporción: medido,
     el titular pasaba de 3 renglones a 2 y .hero__inner saltaba 95 px. O sea
     que el arreglo del CLS horizontal creaba uno vertical el doble de grande
     (0,088 → 0,190). En `em` la caja no depende de la fuente: queda fija y el
     único que se mueve es el texto, que es lo que el size-adjust ya iguala.
     Comprobado a 390/768/1024/1440/1920/2560: 3 renglones en los seis y
     delta de .hero__inner = 0,0 px. */
  max-width: 9.2em;
  text-shadow: 0 2px 30px rgba(29,28,24,.5);
}
.hero__title-dim { color: var(--ink-soft); }
.hero__title-hi  { color: var(--ink); }

/* El fragmento marcado. inline-block para que sea UNA caja: el hairline es un
   ::after absoluto y con un span en línea partido en dos renglones se dibujaría
   sobre el fragmento equivocado. Sin overflow y sin padding a propósito — eso
   es exactamente lo que hacía que .word corriera la línea de base y creciera el
   H1 21px después del primer paint (era el culpable nº1 del CLS de la home). */
.hero__title-key {
  color: var(--ink);
  position: relative;
  display: inline-block;
}
.hero__title-key::after {
  content: ""; position: absolute;
  left: 0; right: 0; bottom: .02em; height: 1px;
  background: var(--accent-2);
  transform-origin: left center;
}

/* ==========================================================================
   LA COREOGRAFÍA · frentes vi + vii
   UNA SOLA línea de tiempo. Antes había dos escaleras de delays calculadas por
   separado en JS —150+idx*110 para los ítems y --i*55ms por palabra— disparadas
   al parsearse el script: no era una línea de tiempo, eran dos relojes que
   casualmente arrancaban juntos. Ahora los tiempos viven todos acá, en una
   lista que se lee de arriba abajo, y arrancan cuando el diafragma abre.

   t = 0 es el instante en que el diafragma EMPIEZA a abrir, no cuando termina:
   el título tiene que resolver MIENTRAS el agujero crece, o son dos gestos en
   fila en vez de uno solo.
   ========================================================================== */
.hero {
  /* Los tiempos NO son de gusto: están puestos contra el recorrido medido del
     diafragma. La apertura descubre la caja del título recién cuando su radio
     pasa los ~480px, o sea al 66% de su duración. Con la primera versión de
     estos números el blur-to-focus entero corría DETRÁS de la plancha: cuando
     el título quedaba a la vista el desenfoque ya medía 0,63px, imperceptible.
     El efecto existía y no se veía — que es la trampa que este proyecto ya
     tiene anotada tres veces. Ahora el título emerge todavía desenfocado. */
  --t-eyebrow: 340ms;
  --t-titulo:  380ms;
  --t-clave:   680ms;   /* 300 ms después del título — el "300 ms" del brief */
  --t-subhead: 860ms;
  --t-ctas:    950ms;
  --t-micro:  1040ms;
  --t-cue:    1140ms;

  --hero-blur:  14px;
  --hero-scale: 1.02;
}

/* SEGUNDA VISITA · la misma coreografia, recalibrada — y NO es un detalle.
   Los tiempos de arriba estan puestos contra el recorrido del diafragma: el
   titulo espera a que la apertura lo descubra. Sin diafragma no hay nada que
   esperar, y con esos mismos numeros el hero quedaba MEDIO SEGUNDO como una
   foto sin titular (verificado mirando el cuadro a escala real, no en
   miniatura). Era una regresion propia, y solo aparecia en la segunda visita.
   Aca el desenfoque arranca mas chico, resuelve con la curva de la casa y todo
   entra antes: el lente sigue enfocando, pero delante de nadie que lo tape. */
html.obs-visto .hero {
  --t-eyebrow:  60ms;
  --t-titulo:   60ms;
  --t-clave:   360ms;
  --t-subhead: 480ms;
  --t-ctas:    560ms;
  --t-micro:   640ms;
  --t-cue:     740ms;

  --hero-blur:  9px;
  --hero-scale: 1.012;
}

/* ESTADO DE PARTIDA. Cuelga de html.obs-tl, clase que pone el script inline del
   loader. Si ese script tira antes de ponerla —o si no hay JS— no se esconde
   nada y el hero renderiza entero. */
html.obs-tl .hero [data-hero-item] { opacity: 0; }

/* OJO: el título NO va en opacity 0, y no es un detalle de estilo. Un elemento
   en opacity 0 deja de ser candidato a LCP, y medido con Lighthouse el elemento
   LCP de esta página ES el H1 (no la pintura del hero). filter y transform no
   lo descalifican: el texto se pinta igual y a la misma hora. */
html.obs-tl .hero__title { filter: blur(var(--hero-blur)); transform: scale(var(--hero-scale)); }
html.obs-tl .hero__title-key,
html.obs-tl .hero__title-hi { color: var(--ink-soft); }
html.obs-tl .hero__title-key::after { transform: scaleX(0); }

/* (a) BLUR-TO-FOCUS — es enfocar un lente, encadenado detrás del diafragma.
   blur(0px) explícito y no none: none no interpola parejo entre motores (misma
   lección que obs-settle en base.css). */
@keyframes obs-hero-focus {
  from { filter: blur(var(--hero-blur)); transform: scale(var(--hero-scale)); }
  to   { filter: blur(0px);              transform: scale(1); }
}
/* (b) REVELADO DE SIGNIFICADO — la animación señala el argumento. */
@keyframes obs-hero-ignite {
  from { color: var(--ink-soft); }
  to   { color: var(--ink); }
}

/* La curva NO es --ease-expo, por el mismo motivo que la del diafragma: sobre un
   RADIO de desenfoque, una curva adelantada consume los 14px en el primer
   cuarto. Medido con ease-expo: a los 500 ms quedaban 2,6px y a los 700 ms,
   0,63. Esta curva deja desenfoque real hasta bien entrado el barrido. */
.hero.is-running .hero__title {
  animation: obs-hero-focus 820ms cubic-bezier(.55,.06,.28,1) var(--t-titulo) both;
}
/* Sin plancha adelante, la curva lenta deja el titular ilegible a la vista.
   Aca va --ease-out, que resuelve el foco en el primer tercio. */
html.obs-visto .hero.is-running .hero__title {
  animation: obs-hero-focus 620ms var(--ease-out) var(--t-titulo) both;
}
/* will-change SÓLO mientras dura: el script inline agrega .is-settled en el
   animationend de obs-hero-focus, igual que el módulo 5 limpia el suyo. */
.hero.is-running:not(.is-settled) .hero__title { will-change: filter, transform; }

.hero.is-running .hero__title-key,
.hero.is-running .hero__title-hi {
  animation: obs-hero-ignite 400ms var(--ease-out) var(--t-clave) both;
}
.hero.is-running .hero__title-key::after {
  animation: obs-draw-x 520ms var(--ease-expo) var(--t-clave) both;
}

.hero.is-running [data-hero-item] {
  animation: obs-rise 700ms var(--ease-expo) both;
}
.hero.is-running .eyebrow      { animation-delay: var(--t-eyebrow); }
.hero.is-running .hero__subhead { animation-delay: var(--t-subhead); }
.hero.is-running .hero__ctas    { animation-delay: var(--t-ctas); }
/* El cue lleva las dos: entra con obs-rise y RECIÉN DESPUÉS arranca su pulso.
   Durante su propio delay obs-cue no tiene fill, así que no aporta nada y
   obs-rise queda solo. */
.hero.is-running .hero__cue {
  animation: obs-rise 700ms var(--ease-expo) var(--t-cue) both,
             obs-cue 2.6s var(--ease-inout) calc(var(--t-cue) + 700ms) infinite;
}

/* El blur sobre un H1 de hasta 88px es caro. Se apaga donde duele — y acá el
   título se queda VISIBLE (sin opacity 0) en vez de entrar con otra animación:
   en móvil el LCP ya está al filo y el diafragma abriendo ES la entrada. */
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
  html.obs-tl .hero__title { filter: none; transform: none; }
  .hero.is-running .hero__title { animation: none; }
  .hero.is-running:not(.is-settled) .hero__title { will-change: auto; }
}

.hero__subhead {
  font-size: var(--fs-lead);
  line-height: var(--lh-relaxed);
  /* --ink pleno, no mute: sobre la pintura el mute medía 1,91:1 a 2560 con el
     91% del trazo bajo umbral. No hay velo (la foto va a color completo a
     propósito, ver la nota en .hero__bg picture): en pleno el subhead mejora
     mucho el mute, pero en algunos anchos igual queda por debajo de 4,5:1
     donde la foto es clara detrás del texto — decisión consciente. */
  color: var(--ink);
  max-width: 60ch;
  text-shadow: 0 1px 16px rgba(29,28,24,.45);
  text-wrap: pretty;
}
.hero__ctas { display: flex; gap: var(--sp-4); flex-wrap: wrap; justify-content: center; margin-top: var(--sp-2); }

/* ⚠ EL FANTASMA VA EN --ink PLENO ACÁ — Y ES EL MISMO ARREGLO QUE EL SUBHEAD
   DE ARRIBA (§ 628), QUE EN LA 16ª NO SE LE TRASLADÓ A ESTE BOTÓN.
   `.btn--ghost` usa `--ink-mute`, que en tema oscuro es la crema al 72%, y
   sobre una FOTO eso no llega: medido con `contraste-pagina.mjs` contra el
   fondo REAL del render, **4,24:1 a 390 y a 320** contra un umbral de 4,5.
   Y no es un botón cualquiera: es el CTA de WhatsApp, que es la acción de
   conversión de todo el sitio, y está arriba del pliegue.

   ⚠ EL VIDEO NO ERA LA CAUSA, aunque lo parecía. Se midió el mismo elemento
   con y sin video, y muestreando el video en seis instantes: se mueve 0,08.
   El problema es el color, así que el arreglo va en el color.

   Sólo se toca el TEXTO, que es lo que falla medido. El contorno queda en
   `--line-strong` (crema al 28%), que sobre la foto es casi invisible y
   probablemente no llegue a los 3:1 que WCAG 1.4.11 pide para el límite de un
   control — pero el botón se identifica por su etiqueta, no por su canto, y
   subirlo es una decisión de dirección de arte que no estaba medida. Anotado. */
.hero__ctas .btn--ghost { color: var(--ink); }
/* eyebrow sube de --ink-soft a --ink-mute. Es la SEXTA vez que este token se
   cae encima de algo que levanta el suelo (5ª .proyecto__card, 8ª
   .footer__heading, 11ª .testimonio__role, 12ª .footer__bottom, 14ª el hero
   medido por primera vez). Acá el suelo es una pintura, que es el caso
   extremo. Se queda en MUTE y no en pleno a propósito: en pleno pasa más alto
   pero queda tan brillante como el titular y el hero pierde jerarquía —
   comparado sobre render. El 4,66-5,28 de la 16ª se midió CON el velo
   (§ .hero__bg picture, más arriba); sin velo, sobre la nube clara, cae por
   debajo de 4,5 en algunos anchos — decisión consciente, la foto manda. */
.hero .eyebrow { color: var(--ink-mute); }

.hero__cue {
  margin-top: var(--sp-6);
  width: 1px; height: 44px;
  background: linear-gradient(180deg, transparent, var(--ink));
  opacity: .35;
  transform-origin: top center;
  animation: obs-cue 2.6s var(--ease-inout) infinite;
}
@keyframes obs-cue {
  0%, 100% { transform: scaleY(.35); opacity: .18; }
  50%      { transform: scaleY(1);   opacity: .45; }
}

/* ⚠ EL ORDEN DE ESTOS DOS BLOQUES IMPORTA: los dos declaran el mismo padding
   con la misma especificidad, así que en un teléfono (que matchea los dos)
   gana el último escrito. El de 640 va DESPUÉS. Estaba al revés y por eso el
   teléfono se comía la regla de 1024. */

@media (max-width: 1024px) {
  /* La subida del texto que se había pedido, y lo único que se había pedido.
     El bloque sigue centrado por .hero (align-items:center) y lo que lo mueve
     es la asimetría del padding: (top − bottom) / 2. El tope se apoya en los
     76px clavados del nav más un respiro de --sp-6, y el empuje sale de abajo.
     100/120 deja el texto 10px arriba del centro; antes estaba 30px abajo, o
     sea que sube ~40px. Subirlo además lo aleja de la cabeza de la foto, que
     era el roce que preocupaba. */
  .hero__inner {
    padding: calc(76px + var(--sp-6)) var(--gutter) calc(var(--sp-24) + var(--sp-12) + var(--sp-12));
  }
}

@media (max-width: 640px) {
  /* Mismo criterio contra el nav móvil, que mide 64px (ver nav__inner):
     88/112 deja el texto 12px arriba del centro. */
  .hero__inner {
    padding: calc(64px + var(--sp-6)) var(--gutter) calc(var(--sp-24) + var(--sp-10) + var(--sp-12));
    gap: var(--sp-5);
  }
  .hero__ctas { flex-direction: column; width: 100%; }
  .hero__ctas .btn { width: 100%; }
}

/* ==========================================================================
   02 · CLIENTES ░░
   Los números se mudaron a la 07 — son prueba dura y trabajan mejor
   pegados a las citas que solos en una banda aparte.
   ========================================================================== */
.clientes {
  padding-block: clamp(4rem, 3rem + 3vw, 6.5rem);
  background: var(--bg);
  overflow: hidden;               /* corta el track a ancho de página */
}

/* Cabecera a dos columnas: el eyebrow ancla a la izquierda y el titular
   arranca a un tercio. La raya del eyebrow se cambia por un punto —
   apuntaba a un titular que ya no tiene al lado. */
.clientes__head {
  display: grid;
  grid-template-columns: minmax(0, .5fr) minmax(0, 1fr);
  gap: clamp(1.5rem, 1rem + 2vw, 3rem);
  align-items: start;
}
.clientes__head .eyebrow { padding-top: .55em; }   /* punto a la altura de la 1ª línea */
.clientes__title { max-width: 24ch; }

@media (max-width: 768px) {
  .clientes__head { grid-template-columns: 1fr; gap: var(--sp-4); }
  .clientes__head .eyebrow { padding-top: 0; }
  .clientes__title { max-width: none; }
}

/* Tarjeta de logo — cuadrada, un punto más oscura y más gris que el fondo.
   La sección va en --bg (crema) justamente para que la tarjeta en
   --bg-elev (arena) se lea elevada; al revés se aplanaba. */
.logo-card {
  flex: 0 0 auto;
  width: clamp(148px, 15vw, 216px);
  /* ⚠ ALTO EXPLÍCITO, NO `aspect-ratio`, Y NO ES ESTILO: ES LO QUE ROMPÍA LA
     TIRA. Con aspect-ratio el alto de la tarjeta queda INDEFINIDO para los
     hijos, así que el alto en % del logo no resolvía y la imagen se dibujaba
     a su alto natural. Los logos verticales (my-saint es 734x1038) estiraban
     la tarjeta hacia abajo y la tira terminaba con cajas de cinco alturas
     distintas. Con el mismo valor que el ancho el cuadrado está garantizado
     y los porcentajes de adentro tienen contra qué resolver. */
  height: clamp(148px, 15vw, 216px);
  display: grid; place-items: center;
  /* ⚠ LA FILA VA DECLARADA, NO AUTO. Es la segunda mitad del mismo problema
     que el alto de arriba. Con la fila en `auto` la calcula el ítem, y un
     <img> de grid arrastra `min-height: auto`: su mínimo es el alto natural
     a ancho completo, que en un logo VERTICAL (my-saint: 734x1038 → 242px a
     171px de ancho) es más alto que la tarjeta entera. La fila se inflaba a
     242px, el alto en % del logo pasaba a medirse contra ESA fila en vez de
     contra la tarjeta, y la fila desbordaba hacia abajo: el logo caía 45px
     por debajo del centro. A los otros siete no les pasaba porque son
     apaisados y su mínimo entra holgado. Con la fila fijada en 100% el
     porcentaje tiene contra qué resolver y el centrado es exacto. */
  grid-template-rows: 100%;
  /* Menos aire a los costados que arriba y abajo: cinco de los ocho logos son
     logotipos de 4:1 o más (raco es 7,9:1) y el ancho es su única dimensión
     útil. Con el padding parejo de 2rem quedaban muy chicos. */
  padding: clamp(1.25rem, 1rem + 1vw, 2rem) clamp(.9rem, .6rem + .8vw, 1.4rem);
  border-radius: var(--radius-lg);
  /* --bg-elev crudo sobre --bg daba 4 puntos de diferencia y la tarjeta
     desaparecía; oscurecerla un poco le devuelve el borde sin meter línea. */
  background: color-mix(in srgb, var(--bg-elev) 92%, var(--ink));
  transition: transform var(--dur) var(--ease-out),
              background var(--dur) var(--ease-out);
}

/* Skin de placeholder atenuado. El rayado y las escuadras a tamaño completo
   tapaban la forma de la tarjeta, que es justo lo que hay que poder juzgar
   antes de que lleguen los logos. */
.logo-card.ph { outline-color: color-mix(in srgb, var(--accent-2) 32%, transparent); }
.logo-card.ph::before, .logo-card.ph::after { display: none; }
/* ⚠ NORMALIZACIÓN ÓPTICA — LOS LOGOS NO SE MIDEN POR SU ALTO NATURAL. Un
   logotipo de 4:1 y un isotipo vertical puestos al mismo alto NO pesan igual:
   el compacto se ve al doble. Cada logo entra en una caja de ancho completo y
   alto fijo (--logo-h) y `contain` lo acomoda adentro; --logo-h sale de
   igualar el ÁREA que ocupa cada marca, no su alto. Los muy anchos quedan
   limitados por el ancho y ni miran --logo-h, que es lo correcto.

   Los valores están medidos contra la tira, no calculados: a área igual, una
   marca MACIZA sigue pesando más que una tipografía, así que las dos siluetas
   llenas bajan un escalón más de lo que daría la cuenta. */
.logo-card { --logo-h: 24%; }              /* logotipos de ~4:1 para arriba */
/* my-saint es el único vertical y además el único con centro de masa corrido:
   la cola fina de arriba le infla la caja, así que centrado por caja el cuerpo
   quedaba bajo. El asset ya viene con relleno abajo para que masa y centro
   coincidan, y por ese relleno el % es más alto que el de los demás — mide
   lienzo, no tinta. */
.logo-card--iso      { --logo-h: 44%; }    /* isotipo vertical (my-saint, minuto90) */
.logo-card--insignia { --logo-h: 25%; }    /* marca compacta (frankie-davey, n+uno, setup) */

/* Las cuatro siluetas que trajeron los nueve logos nuevos. Cada valor sale de
   MEDIR y no de mirar: claude/piezas/logos-area.mjs calcula el área de tinta que
   queda pintada dentro de la tarjeta —fracción de píxeles opacos del archivo,
   por el encaje `contain` contra la caja— y busca el % que la iguala a la
   mediana de los ocho que ya estaban, que son los que ya se juzgaron buenos
   contra la tira. Esa mediana son 1446 px², y los nueve caen dentro de ±2%. */
.logo-card--marca  { --logo-h: 48%; }  /* isotipo + palabra, casi cuadrado (vox) */
.logo-card--bloque { --logo-h: 17%; }  /* wordmark de palo seco y grueso (croppers) */
.logo-card--filete { --logo-h: 21%; }  /* logotipo muy apaisado y de trazo fino
                                          (imagen-vr, pisano) */
.logo-card--palo   { --logo-h: 15%; }  /* wordmark macizo de ~5:1 (gastón gbg,
                                          serotoninn). Es el más bajo de todos y
                                          está bien que lo sea: son los de mayor
                                          fracción de tinta —55% y 58%— así que a
                                          igual altura pesarían el doble que un
                                          logotipo de trazo fino. */

.logo-card img {
  width: 100%; height: var(--logo-h);
  min-height: 0;   /* ver la nota de la fila: sin esto el mínimo automático manda */
  object-fit: contain;
  /* ⚠ brightness(0), NO grayscale(1). grayscale respeta la luminancia de cada
     marca: el negro macizo de my-saint caía en un gris y el lockup gris de
     Ascensores en otro, o sea ocho tonos distintos en la misma tira, que es
     justo lo que la tira no puede tener. brightness(0) lleva todo pixel opaco
     a negro y no toca el alfa, así que las ocho siluetas comparten tono y la
     única variable que queda es la opacidad de acá abajo.
     Exige que los ocho assets sean OSCUROS SOBRE TRANSPARENTE: el de Frankie
     Davey venía calado en blanco (para fondo oscuro) y hubo que invertirlo. */
  filter: brightness(0);
  opacity: .62;
  transition: opacity var(--dur) var(--ease-out);
}
/* Al entrar a la tira, todo baja y solo sube lo que se apunta. */
.marquee--logos:hover .logo-card img { opacity: .45; }
.marquee--logos .logo-card:hover img { opacity: 1; }
.logo-card:hover { transform: translateY(-4px); background: var(--bg-sunk); }

@media (prefers-reduced-motion: reduce) {
  .logo-card:hover { transform: none; }
}

/* ==========================================================================
   03 · QUÉ SOLUCIONAMOS — EL LIBRO DE PÉRDIDAS ░░

   Antes era una grilla 2×2 de cuatro cards idénticas. El problema no era el
   estilo de las cards: era que discutían con su propio texto. El lead dice
   "se pierde en goteo, todos los días" —algo acumulativo, lento e invisible—
   y cuatro cards iguales, del mismo alto y con el mismo marco cerrado, dicen
   cuatro ítems paralelos, de igual peso y terminados.

   Ahora es un libro contable: filas con numeración romana minúscula, un rail
   vertical que se dibuja hacia abajo con el scroll, un filete de cierre por
   fila cuyo tramo tramado se extiende MÁS que el de la fila anterior, y la
   línea de TOTAL con su doble regla.

   NADA de sticky ni de pin. La página ya tiene tres mecánicas de scroll-jacking
   (pilas en 04 y 06, track pinneado en 08); este bloque quieto es el descanso.
   ========================================================================== */
.libro {
  /* Las tres columnas del libro, compartidas por la cabecera, las filas y el
     total: es lo único que mantiene alineadas las tres piezas. */
  --libro-col1: 3.5rem;                                     /* numeral + icono */
  --libro-cols: var(--libro-col1) minmax(0, 1fr) minmax(12ch, 24ch);
  --libro-gap: clamp(1.25rem, 0.9rem + 1.2vw, 2.5rem);
  --libro-sangria: clamp(1.25rem, 0.8rem + 1.6vw, 2.5rem);  /* del rail al texto */
  --libro-aire: clamp(1.75rem, 1.2rem + 1.3vw, 2.5rem);     /* respiro de fila */
}

.libro__cuerpo {
  position: relative;
  margin-top: clamp(2.5rem, 1.8rem + 2.6vw, 3.5rem);
  /* SIN overflow: hidden acá ni en ningún ancestro de una fila. `overflow:
     hidden` convierte al elemento en contenedor de scroll, y
     animation-timeline: view() se ata al contenedor más cercano — o sea a él,
     que no scrollea nunca. Es la trampa que dejó el filete de .seam clavado en
     scaleX(1) en la 11ª pasada, misma familia que el pin sticky. */
}

/* ── cabecera de columnas ───────────────────────────────────────────────── */
.libro__cabecera {
  display: grid;
  grid-template-columns: var(--libro-cols);
  column-gap: var(--libro-gap);
  padding: 0 0 var(--sp-3) var(--libro-sangria);
  font-family: var(--font-ui);
  font-weight: 600;
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  /* --ink-mute y NO --ink-soft, a propósito. El soft mide ~4.9:1 sobre la
     crema: pasa, pero al filo, y en este proyecto ya se cayó cuatro veces
     apenas algo levantó el suelo (.proyecto__card 5ª, .footer__heading 8ª,
     .testimonio__role 11ª, .footer__bottom 12ª). Acá no hay muro de plancha
     y el mute no cuesta nada, así que el margen se toma de entrada. */
  color: var(--ink-mute);
}
.libro__cabecera span:first-child { grid-column: 2; }
.libro__cabecera span:last-child  { grid-column: 3; }

/* ── las filas ──────────────────────────────────────────────────────────── */
.libro__filas {
  position: relative;
  list-style: none;
  margin: 0;
  padding: 0;
}
/* Remate del rail: el trazo corto que abre la columna en el libro */
.libro__filas::before {
  content: "";
  position: absolute; left: 0; top: 0;
  width: 9px; height: 1px;
  background: var(--line-strong);
}

.fuga {
  position: relative;
  display: grid;
  grid-template-columns: var(--libro-cols);
  grid-template-areas:
    "num   concepto cuando"
    "icono concepto cuando";
  column-gap: var(--libro-gap);
  align-items: start;
  /* El aire va en padding y NUNCA en margin: las filas tienen que quedar
     pegadas para que los cuatro segmentos de rail se lean como una sola línea.
     Los 12px de abajo reservan el alto del filete, que está fuera del flujo. */
  padding: var(--libro-aire) 0 calc(var(--libro-aire) + 12px) var(--libro-sangria);
}
/* Sin min-height: la fila mide lo que mide su texto. Al revés que .problema,
   que imponía 400-500px justamente para que las cuatro fueran iguales — que
   era la mitad del problema que esta sección venía a resolver. */

/* Segmento de rail. Cuatro segmentos pegados, no un elemento único alto:
   view() mide la visibilidad del PROPIO sujeto, y con un elemento de ~1200px
   el rango útil se degenera y el resultado depende del alto de ventana. Por
   segmento, cada tramo cuelga del reloj de su fila — y "la fila se enciende
   cuando el trazo la alcanza" sale gratis, porque es el mismo reloj. */
.fuga::before,
.libro__total::before {
  content: "";
  position: absolute; left: 0; top: 0; bottom: 0;
  width: 1px;
  background: var(--line-strong);
  transform: scaleY(1);            /* fallback sin scroll-timeline: ya dibujado */
  transform-origin: top;
}

/* El numeral romano en Instrument Serif y a tamaño de numeral, no en Plus
   Jakarta: la ley tipográfica reserva la serif para numerales DISPLAY (no para
   micro-labels, que es por lo que el contador de la 04 quedó en sans). A este
   tamaño es un numeral, y además es la letra con la que un libro del XVIII
   numera sus asientos. */
.fuga__num {
  grid-area: num;
  font-family: var(--font-display);
  font-size: clamp(1.375rem, 1.1rem + 0.9vw, 1.875rem);
  line-height: 1;
  padding-top: .06em;
  color: var(--ink);               /* fallback = estado encendido */
}

/* A 32px desde un viewBox de 96 la escala es 0.33: el stroke-width de 1.25 que
   trae .icon--figure quedaría en 0.42px de trazo aparente y el dibujo casi
   desaparece. 2.8 devuelve un hairline de ~0.93px.

   El tamaño y el peso salieron de comparar cuatro combinaciones a 3x: a 28px
   con trazo 3.6 los cuatro rombos de la fila iii se empastan en una mancha.
   Lo que separa el dibujo es el PESO DEL TRAZO, no el tamaño. */
.icon--fuga {
  grid-area: icono;
  width: 32px; height: 32px;
  margin-top: 10px;
  stroke-width: 2.8;
  stroke: var(--accent-2);         /* fallback = estado encendido */
}

.fuga__concepto { grid-area: concepto; }
.fuga__concepto p {
  margin-top: var(--sp-3);
  max-width: 56ch;
  color: var(--ink-mute);
  line-height: var(--lh-relaxed);
  text-wrap: pretty;
}

.fuga__cuando {
  grid-area: cuando;
  padding-top: .3em;               /* a la altura de la primera línea del h3 */
  font-family: var(--font-ui);
  font-weight: 600;
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  color: var(--ink-mute);
  text-wrap: pretty;
}

/* ── la marca que se extiende ───────────────────────────────────────────── */
/* Fuera del flujo y anclada a left: 0 para que arranque en el RAIL y no en la
   caja de contenido: ahí es donde se forma la esquina del libro. */
.fuga__marca {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 12px;
  pointer-events: none;
}
/* El filete de cierre, de canto a canto */
.fuga__marca::before {
  content: "";
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px;
  background-color: var(--line-strong);   /* fallback = estado encendido */
}
/* El tramado, escrito POR ENCIMA del filete, como se anotan los palotes sobre
   la línea rayada. --peso viene inline por fila.

   OJO con --peso: son cuartos exactos (.25 / .50 / .75 / 1) porque la marca es
   el ACUMULADO CORRIENTE del libro, no la magnitud de cada fuga: cada fila suma
   un cuarto. Valores irregulares (.24 / .51 / .74) insinuarían magnitudes
   medidas — o sea, cifras inventadas, que es justo lo que el manual prohíbe. */
.fuga__marca::after {
  content: "";
  position: absolute; left: 0; bottom: 2px;
  width: calc(var(--peso, 0) * 100%);
  height: 9px;
  /* Vertical y no en diagonal, paso 7px y no 5-6. Elegido comparando cinco
     variantes sobre render a 1:1: en diagonal lee como una muestra de textura,
     y a paso 5-6 el tramado se cierra y lee como un peine gris. A 7px cada
     trazo conserva identidad y la banda lee como palotes contados, que es
     justo la idea de acumulacion. */
  /* La tinta se eligio A ESCALA DE VISTAZO, comparando .38 / .50 / .64 sobre
     el render entero de la seccion reducido: es ahi donde la acumulacion tiene
     que leerse sola, y a --ink-faint (.38) hay que ir a buscarla. A .64 el
     tramado empieza a competir con los h3. Es la leccion de la 8a pasada:
     medir que un efecto existe no es comprobar que se percibe. */
  background-image: repeating-linear-gradient(90deg,
    color-mix(in srgb, var(--ink) 50%, transparent) 0 1px, transparent 1px 7px);
  clip-path: inset(0 0 0 0);       /* fallback: ya dibujado */
}

/* ── la línea de TOTAL ──────────────────────────────────────────────────── */
/* Convención contable: regla simple arriba —que ya está, es el filete de la
   fila iv llegando al 100%— y doble regla abajo. */
.libro__total {
  position: relative;
  display: grid;
  grid-template-columns: var(--libro-cols);
  column-gap: var(--libro-gap);
  padding: clamp(2rem, 1.4rem + 1.6vw, 3rem) 0 0 var(--libro-sangria);
  /* sin padding-bottom: el rail tiene que morir exactamente en la doble regla */
}
.libro__total-label {
  grid-column: 1;
  padding-top: .55em;
  font-family: var(--font-ui);
  font-weight: 700;
  font-size: 11px;
  letter-spacing: .12em;           /* --track-label no entra en la columna */
  text-transform: uppercase;
  color: var(--ink-mute);
}
/* .pull-quote viene centrada (es su único uso previo, y era el pull-quote de
   esta misma sección). Acá se revierte a bandera izquierda: en el libro el
   total es un asiento más, no una cita suelta. */
.libro__total .pull-quote {
  grid-column: 2 / -1;
  align-items: flex-start;
  text-align: left;
  gap: 0;
}
.libro__total .pull-quote p { max-width: 30ch; }

.libro__doble {
  grid-column: 1 / -1;
  margin-left: calc(var(--libro-sangria) * -1);   /* arranca en el rail */
  margin-top: clamp(1.75rem, 1.2rem + 1.4vw, 2.5rem);
  display: grid;
  gap: 3px;
}
.libro__doble i {
  display: block;
  height: 1px;
  background: var(--ink);
  transform: scaleX(1);            /* fallback: ya dibujada */
  transform-origin: left center;
}

/* El CTA vive FUERA de .libro__total: si estuviera adentro, el rail —que va de
   top a bottom del bloque— seguiría de largo por debajo de la doble regla, y la
   idea es justo la contraria: el libro cierra ahí. Se alinea a mano con la
   columna de concepto. */
.libro__cta {
  display: inline-flex;
  margin-top: clamp(1.75rem, 1.3rem + 1.4vw, 2.5rem);
  margin-left: calc(var(--libro-sangria) + var(--libro-col1) + var(--libro-gap));
}

/* ── movimiento ─────────────────────────────────────────────────────────── */
/* Acá SÍ sirve animation-timeline: view(), al revés que en los módulos 11, 13 y
   14, donde el pin sticky congela el progreso. Segundo uso del mecanismo en el
   proyecto, después de .seam.

   TODOS los rangos van en `cover` y ninguno en `entry`, y eso no es estilo: la
   longitud de la fase `entry` ES el alto del sujeto, así que para el filete
   —que mide 12px— `entry 40%` cae a 5px de scroll y la animación entera pasa
   en un parpadeo. `cover` mide viewport + alto, o sea que se comporta parecido
   para un sujeto de 12px y para uno de 200. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {

    /* 1 · el trazo baja */
    .fuga::before,
    .libro__total::before {
      transform: scaleY(0);
      animation: obs-libro-rail linear both;
      animation-timeline: view();
      animation-range: cover 12% cover 42%;
    }

    /* 2 · la fila se enciende cuando el trazo la alcanza.
       Enciende lo que NO es texto. Atenuar la fila apagada con opacity o con un
       token flojo la tiraría debajo de AA — es la lección de la 6ª pasada, donde
       a opacity .52 el cuerpo de la ficha medía 2.25:1. Acá las dos puntas de
       cada transición pasan AA por separado. */
    .fuga__num {
      animation: obs-libro-num linear both;
      animation-timeline: view();
      animation-range: cover 32% cover 52%;
    }
    .icon--fuga {
      animation: obs-libro-trazo linear both;
      animation-timeline: view();
      animation-range: cover 32% cover 52%;
    }
    .fuga__marca::before {
      animation: obs-libro-filete linear both;
      animation-timeline: view();
      animation-range: cover 30% cover 50%;
    }

    /* 3 · el tramado se escribe.
       Con clip-path y no con scaleX: escalando, el tramado se ESTIRA mientras
       dura el scrub y lee como goma. (.rule--dotted acepta ese estirón porque
       dura 640ms y asienta; acá el scrub es continuo y no asienta nunca.) */
    .fuga__marca::after {
      clip-path: inset(0 100% 0 0);
      animation: obs-libro-tramado linear both;
      animation-timeline: view();
      animation-range: cover 40% cover 66%;
    }

    /* 4 · la doble regla del total, de izquierda a derecha */
    .libro__doble i {
      transform: scaleX(0);
      animation: obs-libro-doble linear both;
      animation-timeline: view();
      animation-range: cover 30% cover 58%;
    }
    .libro__doble i:last-child { animation-range: cover 38% cover 66%; }
  }
}

@keyframes obs-libro-rail    { from { transform: scaleY(0); }              to { transform: scaleY(1); } }
@keyframes obs-libro-num     { from { color: var(--ink-mute); }            to { color: var(--ink); } }
@keyframes obs-libro-trazo   { from { stroke: var(--line-strong); }        to { stroke: var(--accent-2); } }
@keyframes obs-libro-filete  { from { background-color: var(--line); }     to { background-color: var(--line-strong); } }
@keyframes obs-libro-tramado { from { clip-path: inset(0 100% 0 0); }      to { clip-path: inset(0 0 0 0); } }
@keyframes obs-libro-doble   { from { transform: scaleX(0); }              to { transform: scaleX(1); } }

/* ── responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
  .libro { --libro-col1: 3rem; --libro-cols: var(--libro-col1) minmax(0, 1fr) minmax(10ch, 18ch); }
}

@media (max-width: 768px) {
  .libro {
    --libro-sangria: clamp(1rem, 4vw, 1.5rem);
    --libro-aire: clamp(1.5rem, 1.2rem + 1vw, 2rem);
  }
  /* Sin tres columnas no hay nada que rotular; el prefijo .visually-hidden de
     cada .fuga__cuando sigue dando el contexto a lectores de pantalla. */
  .libro__cabecera { display: none; }

  .fuga {
    grid-template-columns: auto 1fr;
    grid-template-areas:
      "num      icono"
      "concepto concepto"
      "cuando   cuando";
    column-gap: var(--sp-4);
  }
  .fuga__num { align-self: center; padding-top: 0; }
  .icon--fuga { align-self: center; justify-self: start; margin-top: 0; }
  .fuga__concepto { margin-top: var(--sp-5); }
  /* Sin la tercera columna, la linea de "cuándo pasa" queda pegada a la glosa
     y lee como una frase más del párrafo en negrita. El filete corto la vuelve
     a marcar como lo que es: el otro lado del asiento. */
  .fuga__cuando { margin-top: var(--sp-4); padding-top: 0; }
  .fuga__cuando::before {
    content: "";
    display: inline-block; vertical-align: middle;
    width: 14px; height: 1px; margin-right: .6em;
    background: var(--line-strong);
  }
  .fuga__concepto p { max-width: none; }

  .libro__total { grid-template-columns: 1fr; }
  .libro__total-label { padding-top: 0; }
  .libro__total .pull-quote,
  .libro__doble { grid-column: 1; }
  .libro__cta { margin-left: var(--libro-sangria); }
  .libro__total .pull-quote { margin-top: var(--sp-4); }
  .libro__total .pull-quote p { max-width: none; }
}

/* ==========================================================================
   04 · PROYECTOS + RESULTADOS ░░
   Cinco cards de vidrio que se apilan con scroll sticky. Cada una se pega un
   escalón más abajo que la anterior, así que de las de abajo queda el canto
   asomando: lo que se lee es la PILA, no el reemplazo.

   Diferencia deliberada con el stack de servicios (06), que usa el mismo
   mecanismo: allá las filas son opacas y tapan del todo — no se ve pila, se
   ve reemplazo. Son dos lecturas distintas a propósito. No unificarlas.

   El vidrio sobre crema es el problema difícil de esta sección y se resuelve
   con tres piezas que van juntas (ver más abajo): tinte propio, suelo un tono
   más hondo, y un lavado que le dé algo que desenfocar.
   ========================================================================== */
.proyectos {
  /* Suelo un punto más hondo que sus vecinas. En oscuro esto importa doble:
     --bg-sunk es más negro que el --bg de muestras y servicios, así que la
     costura entre secciones no desaparece aunque las cuatro sean oscuras. */
  background: var(--bg-sunk);

  /* El vidrio de esta sección. La fórmula es simétrica a propósito: empuja
     --bg-elev hacia --ink, o sea que aclara en tema oscuro y oscurece en
     claro. En las dos direcciones el resultado es el mismo — una card que se
     despega del suelo — y la sección puede volver a clara cambiando solo el
     data-theme del <section>. */
  --proy-glass: color-mix(in srgb, var(--bg-elev) 82%, var(--ink));
}

/* Lavado decorativo detrás de la pila. NO es adorno: backdrop-filter sobre un
   fondo plano no se ve, porque desenfocar un color liso devuelve el mismo
   color liso. Esto es lo que le da trabajo al desenfoque y hace que el vidrio
   lea como vidrio mientras las cards se deslizan por encima. */
.proyectos__wash {
  position: absolute; inset: 0; z-index: 0;
  pointer-events: none;
  overflow: hidden;
  /* Degradé del alto de la sección, además de las dos manchas. Es necesario y
     no adorno: las cards quedan FIJADAS mientras el fondo recorre cinco
     pantallas, así que con manchas puntuales pasaban casi todo el trayecto
     sobre negro plano y el desenfoque no tenía nada que hacer (medido: delta
     de 0 a 1 punto dentro de la card). El degradé le cambia el suelo durante
     todo el recorrido. */
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--accent)   16%, transparent)  0%,
    transparent                                          20%,
    color-mix(in srgb, var(--accent-2) 14%, transparent) 46%,
    transparent                                          70%,
    color-mix(in srgb, var(--accent)   13%, transparent) 100%);
}
.proyectos__wash::before,
.proyectos__wash::after {
  content: "";
  position: absolute;
  width: 62vw; height: 62vw;
  max-width: 780px; max-height: 780px;
  border-radius: 50%;
  filter: blur(120px);
}
.proyectos__wash::before {
  background: var(--accent);
  opacity: .20;
  top: 10%; left: -14%;
}
.proyectos__wash::after {
  background: var(--accent-2);
  opacity: .13;
  bottom: 6%; right: -14%;
}
.proyectos .container { position: relative; z-index: 1; }

/* --------------------------------------------------------------------------
   Cabecera a tres columnas — titular · texto · año.
   align-items: end alinea las tres por la base, que es lo que le da el aire
   de ficha editorial: el año grande "apoya" sobre la misma línea que el
   titular en vez de flotar centrado.
   -------------------------------------------------------------------------- */
.proyectos__head {
  margin-top: clamp(2.5rem, 1.8rem + 2.4vw, 3.5rem);
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, .5fr);
  gap: clamp(1.5rem, 1rem + 2vw, 3rem);
  align-items: end;
}

/* width: fit-content + max-width, y NO display: inline-block: los ítems de
   una grilla se blockifican, así que el inline-block se volvía block y el
   globo del contador terminaba pegado al borde de la columna (que con 1fr
   queda muy lejos del texto) en vez de al titular. La caja tiene que medir
   lo que mide el título, y por eso el max-width va acá y no en el .h2. */
.proyectos__head-title {
  position: relative;
  width: fit-content;
  padding-right: 1.75rem;   /* reserva el lugar del globo, si no se corta */
}
/* El ancho se limita en el .h2 y NO en el envoltorio: ch se mide contra la
   fuente del propio elemento, y en el envoltorio son ch de IBM Plex a 16px
   (la caja quedaba en 163px). Acá son ch de Jakarta al tamaño real del
   titular. El fit-content de arriba lo lee igual: el max-width de un hijo
   acota su aporte de max-content al padre. */
.proyectos__head-title .h2 { max-width: 15ch; }

.proyectos__head-text {
  display: flex; flex-direction: column; align-items: flex-start;
  gap: var(--sp-4);
  max-width: 34ch;
}
.proyectos__intro {
  font-size: var(--fs-sm);
  line-height: var(--lh-relaxed);
  color: var(--ink-mute);
  text-wrap: pretty;
}

/* Decorativo (aria-hidden), así que no tiene contraste que cumplir. Aun así
   va en --ink-soft y no en --ink-faint: a 80px sobre el crema, el faint
   directamente desaparecía. El tamaño ya lo pone por debajo del titular en
   jerarquía; no hace falta apagarlo también de color. */
.proyectos__year {
  justify-self: end;
  color: var(--ink-soft);
}

/* --------------------------------------------------------------------------
   LA PILA

   El sticky va en el <li> y NO en un envoltorio interno, y esto es lo único
   que hace que la pila se acumule: el bloque contenedor de un elemento
   pegado es su PADRE. Metido en un div adentro del li, cada card se
   despegaría al terminar su propio li y la pila se desarmaría a medida que
   avanza. Pegado el li, el bloque contenedor pasa a ser el <ol> y las cinco
   quedan fijadas hasta que la lista termina. Es el mismo montaje que .servicio.

   El desfase por card es lo que resuelve la contradicción de fondo: el vidrio
   es transparente, así que si todas se pegaran al mismo `top` se verían unas a
   través de otras. Con el escalón, ninguna tapa a otra por transparencia — de
   las de abajo se ve el canto, que es justo lo que dibuja la pila.
   -------------------------------------------------------------------------- */
.proyectos__list {
  list-style: none; margin: 0; padding: 0;
  margin-top: clamp(3rem, 2rem + 4vw, 5rem);

  --stack-top:    clamp(88px, 14vh, 132px);  /* despeja la nav fija (~76px) */
  --stack-offset: clamp(14px, 1.8vh, 24px);  /* cuánto asoma cada canto */
  --stack-step:   min(78vh, 700px);          /* recorrido de scroll por card */
}

/* La cola de la pila. Es un <li> vacío y no un padding, y la diferencia no es
   de estilo: el rectángulo que limita a un elemento pegado es la caja de
   CONTENIDO del contenedor, y el padding queda afuera de esa caja. Medido en
   Chromium: con padding-bottom, la quinta card nunca llegaba a fijarse y las
   cinco se despegaban juntas justo cuando la pila tenía que verse entera.
   Este <li> sí extiende la caja de contenido y les da ese margen. */
.proyectos__tail { height: min(46vh, 420px); }

.proyecto {
  position: sticky;
  top: calc(var(--stack-top) + var(--i) * var(--stack-offset));
  /* El li sigue ocupando su lugar en el flujo aunque esté pegado, así que la
     altura de la lista es la suma y de acá sale el recorrido del pin. Sin
     alturas artificiales: no hay spacers ni 500vh a mano. */
  min-height: var(--stack-step);
}

/* --------------------------------------------------------------------------
   La card de vidrio.
   Se compone sobre .glass, que ya trae isolation, overflow, el canto
   degradado del ::before con mask-composite y el corte de backdrop-filter en
   pantallas chicas. Acá solo se pisa lo que el tema claro necesita.
   Nada de ::before propio: .glass::before ya ocupa ese pseudo (la 07 ya se
   tropezó con eso, y ::after tampoco quedó libre: se lo llevó el especular
   del glimpse en la 16ª. Por eso la comilla de la 07 terminó siendo un <span>
   de verdad — ver .bobina__comilla).
   -------------------------------------------------------------------------- */
.proyecto__card {
  /* 72vh/640 y no 66vh/580: la zona de imagen tiene que quedarse con TRES
     CUARTOS de la card, y con la banda de texto ya compactada al mínimo los
     100px extra son lo que cierra la cuenta. El techo lo pone la pila, no el
     gusto: la última card se pega en `--stack-top + 4 × --stack-offset` y de
     ahí para abajo tiene que entrar entera. Medido a 700, 800, 900, 1080 y
     1440 de alto — el peor caso deja 47px libres. Subir más rompe la pila en
     portátiles de 800. */
  height: min(72vh, 640px);
  display: flex; flex-direction: column;
  padding: clamp(.7rem, .45rem + .8vw, 1.1rem);
  border-radius: var(--radius-xl);

  /* 64%: sobre negro el vidrio aguanta mucha más transparencia que sobre
     crema —hay contraste de sobra para el texto— y ese 36% es lo que deja
     pasar el fondo. Subirlo apaga la refracción; bajarlo empieza a comerse
     el contraste del texto. */
  background: color-mix(in srgb, var(--proy-glass) 64%, transparent);
  -webkit-backdrop-filter: blur(22px) saturate(1.6);
          backdrop-filter: blur(22px) saturate(1.6);

  /* El canto donde el vidrio agarra luz. Va en --ink y no en --bg porque
     sobre negro la tinta es la crema: la línea de arriba tiene que ser el
     borde ILUMINADO. (En tema claro habría que invertirlo a --bg.) Sumado al
     .glass::before, que en oscuro también dibuja en crema, es lo que enciende
     el canto de cada card asomada en la pila. */
  box-shadow:
    inset 0  1px 0 color-mix(in srgb, var(--ink) 34%, transparent),
    inset 0 -1px 0 var(--glass-inner),
    var(--shadow-lift);

  /* Encoge hacia arriba: el canto asomado queda quieto mientras el cuerpo se
     hunde. La escala la escribe el módulo 14 de main.js. */
  transform-origin: 50% 0;

  /* --ink-soft está calibrado contra el --bg de la sección; la card de vidrio
     se apoya 44 puntos más arriba y ahí el mismo token cae a 3.62:1, debajo
     de AA (medido muestreando píxeles del render, no a ojo). Se sube al
     siguiente escalón del sistema, que mide 4.97:1. Redefinirlo acá y no en
     cada regla arrastra también a los .tag de adentro, que lo heredan. */
  --ink-soft: var(--ink-mute);
}

/* Los .tag de la cabecera de la card suben UN escalon mas, a --ink pleno.
   Medido en la 16a pasada: en --ink-mute dan 4,02:1 con la card despejada
   —ya bajo umbral— y caen a 3,41:1 mientras la card SIGUIENTE de la pila los
   tapa con su vidrio. Las dos causas suman, pero la base ya fallaba sola.
   Es la sexta vez que este token se cae encima de algo que levanta el suelo;
   aca lo que lo levanta es otra card de vidrio encima. */
.proyecto__card .tag { color: var(--ink); }

/* La zona de imagen. flex:1 en vez de aspect-ratio: así la card entra siempre
   en pantalla —condición para que la pila se vea— y el media absorbe la
   diferencia de alto. El radio interior es --radius (12px) contra los 28 de
   la card: la diferencia es el padding. */
.proyecto__link {
  position: relative; display: block;
  flex: 1; min-height: 0;
  border-radius: var(--radius);
  overflow: clip;
  color: inherit; text-decoration: none;
}
/* cursor: none SOLO cuando el link navega de verdad. Mientras la ficha de
   caso no exista, el <a> va sin href y el cursor nativo se queda: esconderlo
   prometería una interacción que todavía no está. */
.proyecto__link[href] { cursor: none; }

.proyecto__media {
  position: absolute; inset: 0;
  border-radius: inherit;
  /* Hunde la zona de imagen respecto del vidrio: le da estructura interna a
     la card en vez de leerse como un panel liso. Va contra --bg-sunk (el
     suelo de la sección) y no contra --ink, que sobre negro ACLARARÍA. */
  background: color-mix(in srgb, var(--bg-sunk) 55%, transparent);
  transition: transform var(--dur-slow) var(--ease-out);
}
.proyecto__media > * { width: 100%; height: 100%; border-radius: inherit; }
.proyecto__media img { object-fit: cover; }
/* La regla de arriba da por hecho que el hijo directo del media es el <img>.
   La captura entra envuelta en un <picture> —hace falta para elegir encuadre
   por breakpoint y para el fallback AVIF→JPG— y <picture> es INLINE por
   defecto, así que el width/height del `> *` no aplica y la imagen sale a
   TAMAÑO NATURAL. Es el mismo caso que .hero__bg (línea 358) y que
   .ncab__cuadro / .nsat en nosotros.css: mismo arreglo, mismas dos líneas. */
.proyecto__media picture { display: block; }
.proyecto__media picture img { width: 100%; height: 100%; border-radius: inherit; }

/* El placeholder va SIN relleno: solo el rayado y el contorno. Es lo que deja
   ver el vidrio a través de la zona de imagen; con background-color puesto
   —como estaba cuando el marco era opaco— la transparencia moría justo en el
   medio de la card. El día que entre la captura real, el <img> tapa esto y el
   relleno deja de importar. */
.proyecto__media .ph { background-color: transparent; }
/* Las escuadras van a 8px, que en el radio interior caen sobre la curva. */
.proyecto__media .ph::before { top: 12px; left: 12px; }
.proyecto__media .ph::after  { bottom: 12px; right: 12px; }

/* --------------------------------------------------------------------------
   Las pastillas — ahora la cabecera de la card, no una capa flotante.

   En el diseño anterior flotaban sobre la imagen, que es el gesto de la
   referencia. Con la pila no se puede: la card que sube tapa a la anterior
   DE ABAJO HACIA ARRIBA, así que todo lo que esté al pie es lo primero que
   desaparece. Medido en Chromium: durante más de la mitad de cada transición
   no quedaba a la vista ni el rubro ni el resultado, solo dos rectángulos
   vacíos. Es la misma lección que .servicio ya tenía escrita — lo que
   identifica va arriba, que es lo que sobrevive al tapado.
   -------------------------------------------------------------------------- */
.proyecto__head {
  flex: 0 0 auto;
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-4);
  padding: var(--sp-1) var(--sp-2) var(--sp-3);
}
.proyecto__pill {
  display: inline-flex; align-items: center; gap: var(--sp-2);
  padding: .4rem .8rem;
  border-radius: var(--radius-pill);
  /* Un punto más clara que la card, para leerse como ficha apoyada encima.
     Sin backdrop-filter a propósito — desde que dejó de flotar sobre la imagen
     no tiene nada que desenfocar salvo el relleno liso de la card, y eran diez
     capas de blur (dos por card) sin efecto visible. */
  background: color-mix(in srgb, var(--ink) 13%, transparent);
  box-shadow: inset 0 0 0 1px var(--line);
  transition: transform var(--dur) var(--ease-out);
}
.proyecto__pill--index {
  flex-shrink: 0;
  font-family: var(--font-ui); font-weight: 600;
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--track-label);
  /* Tinta plena y no --ink-soft: sobre el relleno de la pastilla, que es más
     claro que la card, el soft se quedaba en 4.50:1 — justo en el filo de AA.
     La jerarquía contra el rubro la sostienen el peso y el punto de acento,
     no el apagado. */
  color: var(--ink);
  font-variant-numeric: lining-nums tabular-nums;
}

.proyecto__dot {
  flex-shrink: 0;
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--accent-2);
}
/* .tag viene en --ink-soft; adentro de la pastilla es el dato principal. */
.proyecto__rubro { color: var(--ink); }
/* Marca de honestidad: estos dos son prototipos funcionales, no entregas
   pagas (ver .claude/product-marketing-context.md). Se borra sacando el span. */
.proyecto__proto {
  padding-left: var(--sp-2);
  border-left: 1px solid var(--line-strong);
  color: var(--ink-soft);
}

/* --------------------------------------------------------------------------
   La franja de información, ahora ADENTRO de la card.
   flex: 0 0 auto — mide lo que mide su contenido y le deja el resto al media.
   Proporción de columnas fija en las cinco aunque solo la 01 tenga métrica:
   si la columna de copy cambiara de ancho card por card, la pila perdería la
   alineación vertical que la sostiene.
   -------------------------------------------------------------------------- */
.proyecto__info {
  flex: 0 0 auto;
  padding: 0 var(--sp-2) var(--sp-2);
  display: grid;
  /* Tres filas y no dos columnas, y el motivo es de ALTURA, no de gusto: con
     el titular encajonado en la columna de 1fr caía a dos y tres renglones, y
     esos ~57px eran justo lo que le faltaba a la imagen para llegar a 3/4.
     A ancho completo entra casi siempre en uno. El "Antes" sube a compartir
     fila con la entrega, que antes estaba sola en la columna derecha.
     La proporción de columnas sigue fija en las cinco: si cambiara card por
     card, la pila perdería la alineación vertical que la sostiene. */
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas:
    "titulo titulo"
    "antes  aside"
    "regla  regla";
  column-gap: clamp(1rem, .5rem + 2vw, 3rem);
  row-gap: var(--sp-2);
  align-items: baseline;
}

/* La regla ahora cierra la franja de copy y separa del área de imagen. */
.proyecto__rule {
  grid-area: regla;
  position: relative;
  margin-top: var(--sp-2);
}
/* El barrido de acento en hover — el idioma de la casa, conservado. */
.proyecto__rule::after {
  content: ""; position: absolute; inset: 0;
  background: var(--accent);
  transform: scaleX(0); transform-origin: left center;
  transition: transform var(--dur-slow) var(--ease-expo);
}

.proyecto__result {
  grid-area: titulo;
  /* Sin max-width: el tope de 32ch era lo que partía el titular en tres
     renglones cuando la banda tiene 1085px de ancho para gastar. */
  text-wrap: balance;
}
.proyecto__before {
  grid-area: antes;
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  color: var(--ink-soft);
  text-wrap: pretty;
}
.proyecto__before .tag { margin-right: var(--sp-2); }

.proyecto__aside {
  grid-area: aside;
  /* En fila y alineado por la base contra el "Antes" de al lado. Apilado
     medía ~80px en la ficha 01 (badge + cifra + epígrafe) y era el bloque más
     alto de la banda; en línea mide lo que mide la cifra. */
  display: flex; flex-direction: row; align-items: baseline;
  gap: var(--sp-3); justify-content: flex-end;
}
.proyecto__metric-value {
  font-family: var(--font-display);
  font-size: 1.75rem; line-height: 1;
  color: var(--accent);
}
.proyecto__metric-label { font-size: var(--fs-xs); color: var(--ink-soft); }
.proyecto__delivery {
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  color: var(--ink-mute);
}

/* --------------------------------------------------------------------------
   Cierre de sección — el CTA de transición.
   -------------------------------------------------------------------------- */
.proyectos__cta {
  margin-top: clamp(3rem, 2rem + 3vw, 5rem);
  display: flex; flex-direction: column; align-items: center;
  gap: var(--sp-5); text-align: center;
}
.proyectos__cta-text {
  font-family: var(--font-ui); font-weight: 700;
  font-size: var(--fs-h4);
  color: var(--ink);
}

/* --------------------------------------------------------------------------
   Hover — solo donde hay puntero de verdad.
   -------------------------------------------------------------------------- */
@media (hover: hover) {
  .proyecto:hover .proyecto__media { transform: scale(1.03); }
  .proyecto:hover .proyecto__pill { transform: translateY(-3px); }
  .proyecto:hover .proyecto__rule::after { transform: scaleX(1); }
}

/* --------------------------------------------------------------------------
   Responsive
   -------------------------------------------------------------------------- */
@media (max-width: 900px) {
  /* A tres columnas el titular ya no entra; el año se va porque es
     decorativo y era lo primero que se aplastaba. */
  .proyectos__head { grid-template-columns: 1fr; gap: var(--sp-6); }
  .proyectos__year { display: none; }
  /* El max-width va SIEMPRE en el .h2 y nunca en el envoltorio, por lo mismo
     que arriba: en el envoltorio son ch de IBM Plex a 16px y el titular
     quedaba encajonado en ~194px cuando había 350 disponibles. */
  .proyectos__head-title .h2 { max-width: 22ch; }
}

/* Se apaga la pila antes de que el apilado moleste. Sin sticky las cards
   vuelven a ser una lista vertical normal, y hay que devolverles la
   separación que en modo pila daba el solapamiento. */
@media (max-width: 1024px) {
  .proyectos__list { display: flex; flex-direction: column; gap: var(--sp-10); }
  .proyecto { position: relative; top: auto; min-height: 0; }
  .proyecto__card { transform: none !important; }   /* anula el scrub del módulo 14 */
}

@media (max-width: 768px) {
  /* Con la ventana angosta el alto en vh da una tira alta y finita: acá manda
     la proporción y la card crece con su contenido. */
  .proyecto__card { height: auto; }
  /* 4/3 y no 16/10: acá la regla de "tres cuartos de card" NO se puede cumplir
     al pie de la letra y conviene dejarlo escrito. En una card de ~290px el
     titular se parte en cuatro renglones y la banda de texto mide sola más de
     250px; para que la imagen fuera el 75% tendría que medir ~750px de alto,
     o sea un retrato 2:5. Lo que sí se hace es subirla todo lo que aguanta un
     encuadre componible. */
  .proyecto__link { flex: 0 0 auto; aspect-ratio: 4 / 3; }
  /* Una sola columna: las áreas se apilan. Sin esto el grid de la banda queda
     pidiendo dos columnas contra un `1fr` y la fila del "Antes" se estira. */
  .proyecto__info {
    grid-template-columns: 1fr;
    grid-template-areas: "titulo" "antes" "aside" "regla";
    row-gap: var(--sp-3);
  }
  .proyecto__aside { justify-content: flex-start; }
  .proyecto__result { max-width: none; }
}

@media (max-width: 640px) {
  /* .glass ya corta su propio backdrop-filter acá por costo, pero el override
     de .proyecto__card le gana por orden de archivo: hay que repetirlo o el
     desenfoque sigue corriendo justo donde más caro sale. Sin blur, la card
     necesita ser casi opaca para no desaparecer. */
  .proyecto__card {
    -webkit-backdrop-filter: none;
            backdrop-filter: none;
    background: color-mix(in srgb, var(--proy-glass) 94%, transparent);
  }
}

@media (max-width: 480px) {
  /* El índice se cruzaba con la etiqueta del placeholder. */
  .proyecto__pill--index { display: none; }
}

/* El apilado es movimiento aunque sea layout, y el kill-switch global de
   base.css no toca `position`. Va explícito, igual que en .servicio. */
@media (prefers-reduced-motion: reduce) {
  .proyectos__list { display: flex; flex-direction: column; gap: var(--sp-10); }
  .proyecto { position: relative; top: auto; min-height: 0; }
  .proyecto__card { transform: none !important; }
  .proyecto__media,
  .proyecto__pill,
  .proyecto__rule::after { transition: none; }
  .proyecto:hover .proyecto__media,
  .proyecto:hover .proyecto__pill { transform: none; }
}

/* ==========================================================================
   05 · MUESTRAS ██ — layout. Los mockups viven en mockups.css
   ========================================================================== */
.muestras { overflow: hidden; }
.muestras__grid {
  margin-top: clamp(2.5rem, 1.8rem + 2.6vw, 4rem);
  display: grid;
  grid-template-columns: 1fr 1.15fr;
  gap: clamp(1.5rem, 1rem + 2vw, 3rem);
  align-items: stretch;
}
.muestra { display: flex; flex-direction: column; gap: var(--sp-5); }
.muestra__caption { display: flex; flex-direction: column; gap: var(--sp-2); }
.muestra__note { font-size: var(--fs-sm); color: var(--ink-soft); max-width: 46ch; }

.muestras__glow {
  position: absolute; z-index: 0; pointer-events: none;
  width: 55vw; height: 55vw; max-width: 640px; max-height: 640px;
  border-radius: 50%;
  background: var(--accent);
  opacity: .07;
  filter: blur(130px);
  top: 20%; left: 50%; transform: translateX(-50%);
}
.muestras .container { position: relative; z-index: 1; }

@media (max-width: 1024px) {
  .muestras__grid { grid-template-columns: 1fr; }
}

/* ==========================================================================
   06 · SERVICIOS ██
   Stack sticky, adaptado de la sección "Soluciones" de brumalab.agency.
   Los cuatro items comparten el mismo `top`, así que se apilan exactamente
   uno encima del otro: el fondo OPACO de .servicio es lo que tapa al
   anterior, y la hairline superior es lo que hace legible el apilado.
   Sin fondo opaco el efecto no existe — se transparentan entre sí.

   La sección no pinta fondo propio: usa el --bg del tema oscuro, el negro
   de marca, igual que muestras y testimonios. Por eso .servicio TAMBIÉN va
   en --bg y no en otro tono: el relleno del item tiene que coincidir
   exactamente con el de la sección o el borde del tapado se ve.
   ========================================================================== */

/* Cabecera: título a la izquierda, medidor a la derecha */
.servicios__head {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: var(--sp-10); flex-wrap: wrap;
}
/* Tope de medida al titular: sin esto el H2 ocupa todo el ancho en desktop
   y el medidor queda pegado al final de la frase, como si fuera parte. */
.servicios__head .section__head { flex: 1 1 30rem; max-width: 44rem; }
.servicios__meter { display: flex; align-items: center; gap: var(--sp-5); }
.servicios__marks { display: flex; align-items: center; gap: var(--sp-3); }
.servicios__mark {
  width: 18px; height: 1px;
  background: var(--line-strong);
  transform-origin: left center;
  transition:
    background var(--dur) var(--ease-out),
    transform var(--dur) var(--ease-out);
}
.servicios__mark.is-active { background: var(--accent-2); transform: scaleY(2); }

/* --------------------------------------------------------------------------
   La lista
   -------------------------------------------------------------------------- */
.servicios__list {
  list-style: none; margin: clamp(3rem, 2rem + 3vw, 4.5rem) 0 0; padding: 0;
}

.servicio {
  position: sticky;
  top: clamp(88px, 16vh, 150px);      /* despeja la nav fija (~76px) */
  background: var(--bg);               /* opaco: es lo que tapa al anterior */
  padding-block: var(--sp-6) clamp(2.5rem, 2rem + 2.4vw, 4.5rem);
}
.servicio__rule { margin-bottom: var(--sp-8); }

.servicio__in {
  display: grid;
  /* minmax(0,…) y no 1fr a secas: sin eso un título largo desborda la columna */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: var(--sp-10);
  align-items: start;
}

/* Columna izquierda -------------------------------------------------------
   Numeral y título van en la MISMA línea, y es estructural, no estético:
   mientras el servicio siguiente se desliza encima, lo último que queda a
   la vista del anterior es este renglón. Partido en dos líneas, el tapado
   corta el título por la mitad y parece un error de render. */
.servicio__head {
  display: flex; align-items: baseline; gap: var(--sp-4);
  margin-bottom: var(--sp-6);
}
/* El numeral no es decorativo: 01–04 caen sobre Fig 01–04 de la sección 03,
   en el mismo orden. Es la única numeración de la página que carga
   información, así que se la deja leer. */
/* Tipografía, color y tamaño salen de .num-paso (base.css, Familia A). Acá
   queda sólo lo que es del layout de esta fila. Este era el tratamiento de
   referencia de la familia: los otros dos se alinearon con él. */
.servicio__num { flex-shrink: 0; }
.servicio__head .h3 { text-wrap: balance; }

/* Columna derecha --------------------------------------------------------- */
.servicio__aside { display: flex; flex-direction: column; gap: var(--sp-6); }
.servicio__ph {
  aspect-ratio: 16 / 9; width: 100%; max-width: 26rem;
}
/* El flex-direction/gap que vivía acá era código muerto: dependían del
   display:flex que aportaba la clase .ph, que el markup ya no lleva. El
   centrado real lo pone ahora .servicio__ph--icono. */

/* --------------------------------------------------------------------------
   La pieza: ícono 3D + rótulo. Reemplaza a las cuatro capturas/fotos, que no
   formaban familia (una captura viva, un mockup de chat, un recorte de panel y
   una foto de banco, en cuatro cajas contiguas de la misma lista).

   Los SVG son assets/ico-servicio-*.svg: contraparte SÓLIDA de los cuatro
   line-art de las fugas (sección 03), mismo viewBox 0 0 96 96 y misma silueta.
   La fuga es el problema en línea; el servicio es la misma figura en volumen.
   Por eso los numerales 01–04 siguen cayendo sobre Fig 01–04, como ya dice el
   comentario de .servicio__head.

   RADIO: acá sí va. claude/piezas/servicios/pieza.css:1-7 daba por hecho que
   "el redondeo lo pone el contenedor real (.servicio__ph--media,
   overflow:hidden)" — y ese contenedor nunca tuvo border-radius, así que las
   cuatro piezas viejas salían a esquinas rectas contra lo que su propia fuente
   documentaba. Queda cerrado.
   -------------------------------------------------------------------------- */
.servicio__ph--icono {
  position: relative; overflow: hidden;
  display: grid; align-content: center; justify-items: center; gap: .3rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  /* Viñeta alta: la luz cae desde arriba, igual que el canto de los íconos. */
  background:
    radial-gradient(130% 100% at 50% 4%, rgba(234, 227, 214, .055), transparent 64%),
    var(--bg-sunk);
}
/* Encuadre, NO grilla. La primera pasada usaba un cuadriculado repetido cada
   34px y leía como hoja de cuaderno: competía con el ícono en vez de sostenerlo.
   Las referencias tienen cuatro hairlines cruzando la card de lado a lado. */
.servicio__ph--icono::before,
.servicio__ph--icono::after { content: ''; position: absolute; pointer-events: none; }
.servicio__ph--icono::before { inset: 0 14%;  border-inline: 1px solid var(--line); }
.servicio__ph--icono::after  { inset: 13% 0;  border-block:  1px solid var(--line); }

/* clamp y no un tamaño fijo: la caja topa en 26rem, así que de 1440 para
   arriba el ancho no cambia — pero de 640 abajo pierde el tope y se estira a
   toda la columna, y ahí un valor fijo dejaba el ícono perdido en el medio. */
.servicio__ico {
  position: relative;
  width: clamp(76px, 26%, 112px); height: auto;
}
.servicio__ph-label {
  position: relative;
  font-family: var(--font-ui); font-weight: 600; font-size: .75rem;
  letter-spacing: .06em; color: var(--ink-soft);
}
.servicio__desc {
  max-width: 46ch;                     /* tope de medida, no de layout */
  color: var(--ink-mute);
  line-height: var(--lh-relaxed);
  text-wrap: pretty;
}

/* El 04 es la entrada gratuita: el numeral toma el acento frío para
   despegarlo de los tres pagos sin agregarle una caja ni un badge. */
/* El cuarto servicio (la auditoría) ya se distingue por el chip --accent de
   "Sin costo"; recolorear además su numeral rompía la familia. */

/* --------------------------------------------------------------------------
   Cierre
   -------------------------------------------------------------------------- */
.servicios__foot {
  display: flex; flex-direction: column; align-items: center; gap: var(--sp-8);
  margin-top: clamp(3rem, 2.2rem + 3vw, 5rem);
  text-align: center;
}
.servicios__note {
  max-width: 70ch; margin: 0 auto;
  font-size: var(--fs-sm); color: var(--ink-mute);
  text-wrap: pretty;
}

/* --------------------------------------------------------------------------
   Responsive — el sticky se apaga antes de que el apilado moleste
   -------------------------------------------------------------------------- */
@media (max-width: 1024px) {
  .servicio { position: relative; top: auto; }
  .servicios__head { align-items: flex-start; }
}
@media (max-width: 768px) {
  .servicio__in { gap: var(--sp-6); }
  .servicio__ph { max-width: none; }
}
@media (max-width: 640px) {
  .servicio__in { grid-template-columns: 1fr; }
  .servicios__meter { gap: var(--sp-4); }
  /* Los chips se quedan: cargan el detalle concreto de cada servicio y en
     una sola columna cuestan poco alto. (La referencia los oculta acá.) */
}

/* El apilado es movimiento aunque sea layout, y el kill-switch global de
   base.css no toca `position`. Va explícito. */
@media (prefers-reduced-motion: reduce) {
  .servicio { position: relative; top: auto; }
  .servicios__mark { transition: none; }
}

/* ==========================================================================
   07 · TESTIMONIOS ██
   Prueba dura arriba (las cifras), prueba blanda abajo (las citas).
   Los .numeros vivían en la 02; se mudaron acá enteros.
   ========================================================================== */
.numeros {
  margin-top: clamp(2.5rem, 1.8rem + 2.4vw, 3.5rem);
  display: grid; grid-template-columns: repeat(3, 1fr);
}
/* Sin border-top: el titular está justo encima y la separación con las
   cards ahora la dibuja .testimonios__rule. */
.numero { text-align: center; padding-inline: var(--sp-6); border-left: 1px solid var(--line); }
.numero:first-child { border-left: none; }
.numero__value { color: var(--ink); }
.numero__label { font-size: var(--fs-sm); color: var(--ink-mute); margin-top: var(--sp-3); text-wrap: balance; }

@media (max-width: 640px) {
  .numeros { grid-template-columns: 1fr; gap: var(--sp-8); }
  .numero { border-left: none; border-top: 1px solid var(--line); padding-top: var(--sp-8); }
  .numero:first-child { border-top: none; padding-top: 0; }
}

.testimonios__rule { margin-top: clamp(2.5rem, 1.8rem + 2.4vw, 3.5rem); }

/* --------------------------------------------------------------------------
   LA BOBINA — el reemplazo de las tres cards de vidrio (28ª pasada)

   Antes acá vivían tres .card--glass.testimonio en .grid--3: tres cajas
   iguales, quietas, cada una con su comilla fantasma. Ahora hay UNA sola
   superficie de vidrio partida en dos mitades — la bobina de retratos a la
   izquierda, la cita a la derecha— que cambia sola cada 6,5s. El pedido traía
   tres condiciones y las tres se cumplen acá:

     · el muro de plancha se queda (vive en la <section>, no se tocó);
     · la primitiva de vidrio se queda: .bobina ES .glass, con su aro, su
       backdrop-filter y su especular, en vez del bg-muted del componente
       original;
     · sin botones. La mecánica está en el módulo 15 de main.js.

   LA GEOMETRÍA VIVE ACÁ Y NO EN EL JS. --bob-ficha lleva clamp(), así que el
   paso de la bobina cambia con el ancho de la ventana: el JS lo MIDE del DOM
   (mismo criterio que el measure() del módulo 14) en vez de recalcularlo, y
   por eso no hay un solo número duplicado entre las dos hojas.

   PASADA 31ª: --bob-ficha subió de 124 a 155px de tope (×1.25) porque el
   retrato en curso se leía chico con fotos reales de clientes. --bob-visor
   sube la misma proporción (392→490) para no romper la relación que ya
   traía calibrada: a cualquier ancho, las tres columnas (3×ficha + 2×hueco)
   quedan apenas un poco más anchas que el visor, así el degradé de los
   cantos sigue recortando una tajada fina y no la columna entera. Como el
   JS mide el DOM en vez de repetir estos números, no hizo falta tocar
   main.js. -------------------------------------------------------------- */
.bobina {
  --bob-ficha: clamp(110px, 9.5vw, 155px);
  --bob-hueco: var(--sp-2);
  --bob-visor: clamp(335px, 40vw, 490px);
  --bob-desliz: 800ms;
  --bob-espera: 6500ms;

  position: relative;                 /* .glass ya lo pone; explícito porque
                                         de acá cuelgan el pulso y el badge */
  margin: clamp(2.5rem, 1.8rem + 2.6vw, 3.5rem) auto 0;
  max-width: 1060px;
  min-height: 340px;
  display: flex;
  align-items: stretch;
  gap: var(--sp-2);
  padding: 0;                         /* el aire lo pone cada mitad */
}

/* El badge va absoluto, y el z-index NO se escribe: lo hereda de
   `.glass > * { position: relative; z-index: 1 }` (components.css § 1 bis),
   que sigue aplicando porque acá sólo se pisa `position`. sections.css carga
   después de components.css, así que el absolute gana por orden de fuente. */
.bobina__ph {
  position: absolute;
  top: clamp(.9rem, 1.6vw, 1.25rem);
  right: clamp(.9rem, 1.6vw, 1.25rem);
}

/* La región es tabulable (tabindex="0" en el markup) y no porque haya algo
   que activar adentro: es el único modo que le queda al TECLADO de frenar una
   rotación que ya no tiene botones. El foco entra, el módulo 15 la para.

   El anillo se hereda del :focus-visible global de base.css, pero esa regla
   trae además `border-radius: 4px` —pensada para pastillas y enlaces— y sobre
   una superficie de 1060px eso le cuadraría las esquinas al vidrio en el
   momento justo en que el usuario la está mirando. Se devuelve el radio. */
.bobina:focus-visible { border-radius: var(--radius-lg); }

/* ── EL VISOR ──────────────────────────────────────────────────────────────
   Los cuatro cantos se disuelven con DOS máscaras intersecadas, igual que el
   .muro-plancha (components.css § 13): una horizontal y una vertical. El
   @supports de rescate es el mismo criterio — sin mask no se muestra una
   versión rota, se muestra la versión sin disolver. */
.bobina__visor {
  position: relative;
  flex: 0 0 var(--bob-visor);
  align-self: stretch;
  overflow: hidden;
  -webkit-mask-image:
    linear-gradient(90deg, transparent 0%, #000 14%, #000 86%, transparent 100%),
    linear-gradient(180deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
          mask-image:
    linear-gradient(90deg, transparent 0%, #000 14%, #000 86%, transparent 100%),
    linear-gradient(180deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
}
@supports not ((-webkit-mask-composite: source-in) or (mask-composite: intersect)) {
  .bobina__visor { -webkit-mask-image: none; mask-image: none; }
}

.bobina__tren {
  position: absolute; inset: 0;
  display: flex;
  align-items: center;              /* las columnas desbordan parejo arriba y
                                       abajo, y el visor las recorta */
  justify-content: center;
  gap: var(--bob-hueco);
}

/* SIN `will-change: transform`, y es deliberado. La columna del eje mide
   ~4600px de alto después de que el módulo 15 clona la vuelta ×3; promoverla
   a capa permanente serían varios MB de textura para una banda visible de
   340px. Una transición de transform ya se compone sola mientras corre, que
   es exactamente el rato en que hace falta. */
.bobina__columna {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: var(--bob-hueco);
  transform: translateY(var(--bob-y, 0px));
  transition: transform var(--bob-desliz) var(--ease-inout);
}

/* SIN JAVASCRIPT LA COLUMNA ARRANCA UN TESTIMONIO MÁS ABAJO, y el
   desplazamiento es exactamente un paso. Los cinco rellenos de arriba y los
   tres de abajo dejan centrado, por construcción, al retrato DEL MEDIO de la
   vuelta escrita a mano; pero la cita que se ve sin JS es la primera, así que
   sin esto la bobina mostraría la cara equivocada. Un paso de corrección y
   quedan de acuerdo. Con JS este valor no llega a verse: el módulo 15 escribe
   --bob-y inline, que gana. */
.bobina__columna--eje { --bob-y: calc(3 * (var(--bob-ficha) + var(--bob-hueco))); }

/* La vuelta clonable no puede generar caja propia: si lo hiciera, sus nueve
   ranuras quedarían adentro de UN item flex y perderían el gap de la columna.
   display: contents la borra del árbol de cajas y deja pasar a los hijos.
   ⚠ Como consecuencia, un retrato de adentro NO tiene a la columna como
   offsetParent — por eso el módulo 15 mide con getBoundingClientRect() y no
   con offsetTop. */
.bobina__ciclo { display: contents; }

/* ── LAS FICHAS ────────────────────────────────────────────────────────────
   ⚠ LAS FICHAS DE RELLENO NO LLEVAN LA CLASE .glass, Y ES A PROPÓSITO.
   .glass arrastra backdrop-filter y, bajo html.obs-glimpse, un ::after con
   conic-gradient POR ELEMENTO. Acá hay más de treinta fichas: es exactamente
   el costo que components.css § 1 midió cuando dejó la pila de proyectos
   afuera del glimpse (1773 de 1794 ms de raster los pagaban cinco superficies
   con blur). Así que el aspecto se copia a mano —tinte, pelo y desenfoque— y
   no se paga una sola capa cara.

   El blur va en la COLUMNA en los lados (un filtro para las doce) y por ficha
   sólo en el eje, donde los retratos tienen que quedar nítidos. */
.bobina__ficha,
.bobina__retrato {
  flex: none;
  width: var(--bob-ficha);
  height: var(--bob-ficha);
  border-radius: var(--radius);
}
.bobina__ficha {
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--ink) 9%, transparent),
    color-mix(in srgb, var(--ink) 3.5%, transparent)
  );
  box-shadow:
    inset 0 var(--hairline) 0 rgba(var(--glass-rgb), .10),
    0 1px 2px rgba(0, 0, 0, .22);
}
.bobina__columna--lado { filter: blur(1px); }
.bobina__columna--eje .bobina__ficha { filter: blur(1px); }

/* ── EL RETRATO ────────────────────────────────────────────────────────────
   La ficha nítida. No es una foto de cliente: es un busto renacentista del
   rastro de /nosotros/ (ver el comentario del markup). */
.bobina__retrato {
  position: relative;
  overflow: hidden;
  background: var(--bg-sunk);
  box-shadow:
    var(--shadow-lift),
    inset 0 var(--hairline) 0 rgba(var(--glass-rgb), .26);
}
.bobina__retrato img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: center 30%;
}

/* El destello diagonal del componente original, repintado. El suyo iba en
   rgb(108,92,255) —un violeta que no existe en esta paleta— así que pasa a
   cobre con una lengua de celeste, que son los dos acentos de la casa.
   La capa `mix-blend-saturation` blanca del original SE SACA: servía para
   desaturar retratos a color, y los rastro-* ya son sepia; aplicarla los
   apagaba sin ganar nada.

   Dos declaraciones a propósito: la segunda interpola en oklab para que el
   tramo hacia `transparent` no pase por el negro y deje una orla sucia. Un
   navegador que no parsee `in oklab` se queda con la primera, cuya orla es de
   todos modos invisible bajo blur(6px) y mix-blend-overlay. */
.bobina__destello {
  position: absolute; inset: 0; z-index: 2;
  pointer-events: none;
  filter: blur(6px);
  mix-blend-mode: overlay;
  /* .55 elegido sobre render comparando los tres retratos. A 1 —el valor del
     componente original— el busto frío (rastro-1) quedaba bien pero el cálido
     (rastro-4) se quemaba entero: overlay multiplica lo que ya es naranja.
     El destello tiene que leerse como luz raspando la piedra, no como filtro. */
  opacity: .55;
  background: linear-gradient(221deg,
    transparent 32%,
    var(--accent-2) 41%,
    color-mix(in srgb, var(--accent-2) 45%, var(--ink)) 47%,
    color-mix(in srgb, var(--accent) 57%, transparent) 54%,
    transparent 65%);
  background: linear-gradient(221deg in oklab,
    transparent 32%,
    var(--accent-2) 41%,
    color-mix(in srgb, var(--accent-2) 45%, var(--ink)) 47%,
    color-mix(in srgb, var(--accent) 57%, transparent) 54%,
    transparent 65%);
}

/* ── EL TEXTO ──────────────────────────────────────────────────────────────
   .bobina__texto es hijo directo de .glass, así que ya viene con
   position: relative de components.css § 1 bis: es el bloque contenedor
   contra el que resuelve la comilla. */
.bobina__texto {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem);
}

/* Mismas coordenadas y misma opacidad que tenía en .testimonio__quote: es el
   glifo de siempre, mudado de tarjeta. z-index 0 para que quede DEBAJO del
   texto de la cita (que está en 1 por ser hijo directo del flex) sin taparlo. */
.bobina__comilla {
  position: absolute;
  /* En ancho, la cita mide 48ch y la mitad derecha de esta mitad queda vacía
     —el componente original tenía el mismo hueco, con un ícono adentro—. Acá
     lo ocupa el glifo, en grande y al 7%, que es el idioma de la casa para
     esto (.card__ghost-num, components.css § 3). Centrado en vertical y NO
     pegado al canto de arriba: ahí el tope de la superficie se lo cortaba por
     la mitad, porque .bobina__texto llega hasta el borde del panel. */
  top: 50%;
  right: clamp(1rem, 2.4vw, 2.5rem);
  transform: translateY(-54%);
  z-index: 0;
  font-family: var(--font-display);
  font-size: clamp(6rem, 2rem + 6vw, 9rem);
  line-height: 1;
  color: var(--ink);
  opacity: .07;
  pointer-events: none;
  user-select: none;
}

.bobina__escenario { position: relative; width: 100%; max-width: 48ch; }

/* LA COPIA QUE MIDE. En flujo, invisible: le da al escenario la altura de la
   cita actual a cualquier ancho, así el texto envuelto no se recorta al
   cambiar. Sin ella la capa absoluta no tendría de dónde sacar alto.
   min-height para que el escenario no se desplome entre citas cortas. */
.bobina__medida {
  visibility: hidden;
  display: flex; flex-direction: column; gap: var(--sp-4);
  min-height: 9.5rem;
}
.no-js .bobina__medida { visibility: visible; }

.bobina__capa {
  position: absolute;
  inset-inline: 0;
  top: 0;
  display: flex; flex-direction: column; gap: var(--sp-4);
}
.bobina__capa.is-saliendo {
  animation: obs-bob-sale 240ms var(--ease-out) both;
}

.bobina__cita {
  margin: 0;
  font-size: var(--fs-lead);
  line-height: var(--lh-relaxed);
  color: var(--ink);
  text-wrap: pretty;
}
.bobina__autor {
  margin: 0;
  font-family: var(--font-ui);
  font-size: var(--fs-sm);
  /* ⚠ --ink-mute Y NO --ink-soft, y es la cuarta vez que aparece este patrón
     en la página (.proyecto__card 5ª pasada, .footer__heading 8ª,
     .testimonio__role 11ª). --ink-soft está calibrado contra el --bg de la
     sección, pero el muro de plancha aclara el vidrio hasta rgb(49,47,41) y
     ahí el mismo token cae a 4.32:1, debajo de AA. Se mide muestreando el
     fondo REAL del render, no el token. Si alguna vez se baja la opacidad del
     muro, revisar esto de nuevo. */
  color: var(--ink-mute);
}
.bobina__nombre { font-weight: 700; color: var(--ink); }

/* La partición por carácter. El delay lo escribe el JS inline, carácter por
   carácter; acá sólo vive el estado de partida y la curva.
   La palabra va en su propio inline-block con nowrap para que el salto de
   línea natural siga cayendo entre palabras y no entre letras. */
.bobina__pal  { display: inline-block; white-space: nowrap; }
.bobina__char {
  display: inline-block;
  opacity: 0;
  transform: translateY(.42em);
  animation: obs-bob-sube 520ms var(--ease-out) both;
}
@keyframes obs-bob-sube { to { opacity: 1; transform: none; } }
@keyframes obs-bob-sale {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(-12px); }
}

/* ── EL PULSO ──────────────────────────────────────────────────────────────
   Un pelo que se llena durante la espera. NO ES UN CONTROL: no se puede
   tocar, no recibe foco y es aria-hidden. Está porque sacar los dos botones
   se llevó también la única señal de que la cosa cambia sola.
   Se sincroniza con el temporizador porque las dos cosas arrancan de cero en
   el mismo lugar: el módulo 15 reinicia la animación en cada vuelta y también
   al volver de una pausa. */
.bobina__pulso {
  position: absolute;
  inset: auto 0 0 0;
  height: var(--hairline);
  background: rgba(var(--glass-rgb), .10);
  overflow: hidden;
}
.bobina__pulso i {
  display: block; height: 100%;
  transform: scaleX(0);
  transform-origin: 0 50%;
  background: linear-gradient(90deg,
    rgba(var(--glass-rgb), .18),
    var(--accent-2));
  animation: obs-bob-pulso var(--bob-espera) linear both;
  animation-play-state: paused;
}
.bobina.is-girando .bobina__pulso i { animation-play-state: running; }
@keyframes obs-bob-pulso { to { transform: scaleX(1); } }

/* Con reduced-motion el kill-switch de base.css lleva toda animación a 1ms,
   así que el pulso aparecería lleno de entrada: una barra de progreso clavada
   al tope es justo la afordancia que miente. Se saca. */
@media (prefers-reduced-motion: reduce) {
  .bobina__pulso { display: none; }
}

/* ── ANGOSTO ───────────────────────────────────────────────────────────────
   La bobina pasa arriba, en banda. Debajo de 640 el vidrio ya cae a tinte
   plano sin backdrop-filter (components.css § 1): esperado, no es un bug. */
@media (max-width: 860px) {
  .bobina {
    flex-direction: column;
    min-height: 0;
  }
  .bobina__visor {
    flex: 0 0 270px;
    width: 100%;
  }
  .bobina__texto { padding-top: var(--sp-6); }
  /* Apilado, la mitad del texto ya no toca el canto de arriba: el glifo cabe
     donde estaba en la card vieja, arriba a la derecha de la cita. */
  .bobina__comilla {
    top: -.1em; right: .06em;
    transform: none;
    font-size: 5rem;
  }
  /* Apilada, la cita ocupa el ancho entero del panel: 62ch la deja en medida
     de lectura en vez de dos renglones de noventa caracteres. */
  .bobina__escenario { max-width: 62ch; }
  /* El piso del escenario baja con el ancho, y no es cosmético: a lo ancho la
     misma cita entra en dos renglones en vez de tres, y un piso pensado para
     tres dejaba ochenta píxeles de vacío al pie del panel. */
  .bobina__medida { min-height: 7rem; }
}
@media (max-width: 640px) {
  .bobina { --bob-ficha: 115px; }
  /* Y acá vuelve a subir: angosto, la cita se va a cuatro renglones. */
  .bobina__medida { min-height: 11rem; }
}

.testimonios__disclaimer {
  margin: clamp(2.5rem, 2rem + 2vw, 3.5rem) auto 0;
  text-align: center;
  font-size: var(--fs-xs);
  color: var(--ink-soft);
}

/* El dato con fuente citada (generador § prueba): .lead normal, centrada como
   el resto de esta sección. La fuente que la sigue es una atribución de ESE
   párrafo, no un disclaimer nuevo — va pegada, no al clamp completo. */
.testimonios__stat { text-align: center; margin: clamp(2.5rem, 1.8rem + 2.4vw, 3.5rem) auto 0; }
.testimonios__stat + .testimonios__disclaimer { margin-top: var(--sp-3); }

/* --------------------------------------------------------------------------
   El muro de plancha (frente ii/iii) va en z-index 0, así que el contenido
   tiene que subir por encima — mismo idioma que .proyectos y .muestras.
   -------------------------------------------------------------------------- */
/* Sólo .testimonios acá: los dos selectores del footer que acompañaban a este
   estaban DUPLICADOS literalmente en el bloque del footer, más abajo. Se
   quedan allá, que es donde se leen. */
.testimonios .container { position: relative; z-index: 1; }

/* --------------------------------------------------------------------------
   RITMO VERTICAL — el padding deja de ser uno solo para toda la página.
   Dos motivos:
   (a) Aritmético: una costura de 160px encima de 143+143 de padding daba 446px
       de aire en una sola junta. Las secciones que tocan costura ceden padding
       de ese lado; el aire total queda parecido al de antes, pero ahora está
       ADENTRO de un elemento que lo justifica en vez de ser vacío.
   (b) De lectura: un único ritmo de punta a punta lee como plantilla. Apretado
       después de una prueba, generoso antes de una idea nueva.

   ⚠ ESTE BLOQUE Y LA REGLA DE RITMO DE base.css SON DOS CAPAS, no dos versiones
   de lo mismo, y el orden importa:

     · base.css resuelve el caso GENÉRICO — dos secciones seguidas que comparten
       data-theme aflojan las dos a --section-pad-tight, porque ahí no hay ni
       corte de color ni costura que llene el hueco. Ataca el tramo de cuatro
       secciones oscuras (04→07), que era donde quedaban 304px de vacío.
     · este bloque resuelve los casos CON NOMBRE, y le gana: son declaraciones
       de padding-top sobre la propiedad, mientras que base.css sólo escribe las
       variables --section-pad-top/-bottom que lee el atajo de .section.

   O sea que .formulario sigue siendo generoso (×1.15) y .qa sigue apretado
   (×.70) a propósito, aunque los dos caigan dentro del tramo claro. Si alguna
   vez hay que aflojar uno de ÉSTOS, se toca acá y no allá.
   -------------------------------------------------------------------------- */
/* La costura ES la primera franja de la sección que entra (lleva su color), así
   que la sección entrante descuenta ese alto de su padding de arriba. La que
   sale no cede nada: su corte quedó intacto. */
.clientes  { padding-top: calc(clamp(4rem, 3rem + 3vw, 6.5rem) - 72px); }
.proyectos { padding-top: calc(var(--section-pad) - 88px); }
/* Y aparte, variación de ritmo por lectura: un único --section-pad de punta a
   punta lee como plantilla. Generoso antes de una idea nueva, apretado después. */
.formulario { padding-top: calc(var(--section-pad) * 1.15); }
.qa         { padding-top: calc(var(--section-pad) * .70); }

/* ==========================================================================
   08 · FASES DE TRABAJO ░░

   Pista horizontal pinneada. La <section> solo aporta el runway de scroll
   (--fases-runway); todo lo visible vive en .fases__pin, que queda fijo
   mientras el track se desplaza en X. El desplazamiento lo escribe el
   módulo 14 de main.js — acá solo está la forma.

   Por qué scrub manual y no animation-timeline: view(), que es la convención
   de la casa para lo scroll-tracked: el pin es sticky, y una vez fijado deja
   de moverse dentro del scrollport, así que su progreso se congela. Mismo
   motivo y mismo patrón que la pila de proyectos (04).

   JS escribe UNA custom property por ficha y por cuadro (--focus, 0→1 según
   cercanía al centro del viewport). De ahí salen el fondo, el borde, la
   sombra, la escala, la regla superior y el numeral fantasma, todo por CSS.
   ========================================================================== */
/* Hereda a proposito: la regla de acento y el numeral fantasma son HIJOS de
   la ficha y leen --focus. Con inherits: false resolvian al initial-value y
   los dos efectos quedaban muertos sin avisar. */
@property --focus {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

.fases {
  /* Tunables. Más runway = pase más lento por el mismo recorrido. */
  --fases-runway: 300vh;
  --fases-card: clamp(300px, 32vw, 460px);
  --fases-gap: clamp(1rem, .6rem + 1.1vw, 1.75rem);
  /* El inset lateral iguala el borde del .container de 1200px, así la
     primera ficha arranca alineada con el titular. Va en % y no en vw a
     propósito: vw incluye la barra de scroll y desalineaba 7px contra el
     titular, que se mide sobre el ancho de layout. */
  --fases-inset: max(var(--gutter), calc((100% - var(--container)) / 2 + var(--gutter)));

  height: var(--fases-runway);
  padding-block: 0;          /* el aire lo pone el pin, no la sección */
}

.fases__pin {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100svh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  padding-top: clamp(84px, 12vh, 132px);   /* despeja el nav fijo */
  padding-bottom: var(--sp-8);
}

.fases__head { flex-shrink: 0; }
.fases__head .lead { margin-top: var(--sp-4); max-width: 56ch; }

/* --- Riel de progreso: reemplaza la vieja línea que se dibujaba --- */
.fases__rail {
  position: relative;
  margin-top: clamp(1.25rem, 3.4vh, 2.75rem);
  height: 1px;
  background: var(--line);
}
.fases__rail-fill {
  position: absolute; inset: 0;
  background: var(--accent);
  transform: scaleX(var(--p, 0));
  transform-origin: left center;
}
.fases__tick {
  position: absolute; top: 50%;
  left: calc(var(--t) * 100%);
  width: 7px; height: 7px;
  margin: -3.5px 0 0 -3.5px;
  border-radius: 50%;
  background: var(--bg);
  box-shadow: inset 0 0 0 1px var(--line-strong);
  transition: box-shadow var(--dur) var(--ease-out), transform var(--dur) var(--ease-spring);
}
.fases__tick.is-lit {
  background: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
  transform: scale(1.35);
}

/* --- Pista --- */
.fases__viewport {
  flex: 1;
  display: flex;
  align-items: center;
  overflow: hidden;
  min-height: 0;             /* si no, el flex item no deja encoger la pista */
}
.fases__track {
  display: flex;
  align-items: stretch;
  gap: var(--fases-gap);
  margin: 0;
  list-style: none;
  will-change: transform;
  /* Alto propio para que las fichas llenen el pin: sin esto quedan del alto
     de su texto, flotando, y el "Te queda" nunca baja al pie. El tope deja
     aire sobrante que el centrado reparte arriba y abajo — y ese aire de
     arriba es lo que separa el riel de la regla de acento de cada ficha:
     pegados se leen como una sola linea partida. */
  height: min(100%, 470px);
  /* Inset asimetrico a proposito. A la izquierda, el borde del .container,
     para que la primera ficha arranque alineada con el titular. A la derecha,
     medio viewport, para que la ULTIMA ficha llegue a centrarse en vez de
     quedar estacionada contra el borde sin haber tenido su momento. */
  padding-left: var(--fases-inset);
  padding-right: max(var(--fases-inset), calc(50% - var(--fases-card) / 2));
}
.fase {
  flex: 0 0 var(--fases-card);
  display: flex;
}

/* El aviso de deslizar solo tiene sentido en el modo swipe */
.fases__hint { display: none; }

/* --- La ficha --- */
.fase__card {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  width: 100%;
  display: flex;
  flex-direction: column;
  padding: clamp(1.25rem, .6rem + 2.2vh, 2.25rem) clamp(1.5rem, 1.1rem + 1.6vw, 2.25rem);
  border-radius: var(--radius);

  /* La ficha enfocada se despega de la pagina; las otras se hunden en ella.
     NO se atenua con opacity, que era lo primero a probar y es lo que hace
     Cumbre: bajar la opacidad de la ficha entera tiraba el cuerpo de texto a
     2.25:1, menos de la mitad del AA que pide el protocolo de la casa. La
     jerarquia la hacen fondo, borde, sombra y escala, que no tocan la
     legibilidad — el texto va siempre opaco. */
  background: var(--bg);
  background: color-mix(in srgb, var(--bg-elev) calc(var(--focus) * 100%), var(--bg));
  border: 1px solid var(--line);
  border-color: color-mix(in srgb, var(--line-strong) calc(var(--focus) * 100%), var(--line));
  box-shadow:
    0 2px 4px rgba(var(--glass-rgb), calc(var(--focus) * .05)),
    0 20px 48px -20px rgba(var(--glass-rgb), calc(var(--focus) * .28));
  transform: scale(calc(.94 + var(--focus) * .06));
}

/* Regla de acento que se dibuja al centrarse la ficha */
.fase__rule {
  position: absolute; inset-inline: 0; top: 0;
  height: 2px;
  background: var(--accent);
  transform: scaleX(var(--focus));
  transform-origin: left center;
}

/* Numeral fantasma. La ficha lo recorta con overflow. */
.fase__ghost {
  position: absolute;
  right: -.06em; bottom: -.3em;
  z-index: -1;
  font-family: var(--font-display);
  font-size: clamp(7rem, 12vw, 11rem);
  line-height: 1;
  color: var(--accent-2);
  opacity: calc(.06 + var(--focus) * .18);
  pointer-events: none;
  user-select: none;
}

/* Familia A (base.css). Antes era el único numeral de la página en font-ui +
   --accent, y eso hacía que la ficha se contradijera CONSIGO MISMA: su propio
   fantasma, tres reglas más arriba, ya era display serif sobre --accent-2.

   El numeral chico NO se elimina aunque el fantasma repita la cifra. Son dos
   registros distintos y sólo uno es legible: el fantasma vive entre 6% y 24%
   de opacidad, está recortado por la ficha y es aria-hidden — es textura, no
   dato. Sacando el chico, la ficha se quedaba sin marca de paso visible.
   Tamaño más contenido que en servicios (18→22 contra 28→40) porque acá el
   numeral va APILADO sobre el h3 en vez de en línea con él: al tamaño de
   servicios le competía el titular. */
.fase__num { --num-paso-fs: clamp(1.125rem, 1rem + 0.5vw, 1.375rem); }
.fase__card .h3 { margin-top: clamp(1rem, 4.6vh, 3rem); }
.fase__card .chip { margin-top: var(--sp-3); align-self: flex-start; }
.fase__body {
  margin-top: clamp(.75rem, 2.2vh, 1.25rem);
  color: var(--ink-mute);
  line-height: var(--lh-relaxed);
  text-wrap: pretty;
}

/* Renglón fijo al pie de las cinco fichas: el ojo tiene un ancla mientras pasan */
.fase__deliver {
  margin-top: auto;
  padding-top: clamp(.9rem, 2.2vh, 1.25rem);
  border-top: 1px solid var(--line);
}
.fase__deliver-label {
  font-family: var(--font-ui);
  font-weight: 600;
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--track-eyebrow);
  text-transform: uppercase;
  color: var(--ink-soft);
}
.fase__deliver-text {
  margin-top: var(--sp-2);
  font-family: var(--font-ui);
  font-weight: 600;
  color: var(--ink);
  line-height: var(--lh-snug);
  text-wrap: pretty;
}

/* Ficha de cierre */
.fase__card--cta { border-color: var(--accent); }
.fase__card--cta .eyebrow { margin-bottom: 0; }
.fase__cta-title { margin-top: clamp(1rem, 3.6vh, 2.25rem); }
.fase__deliver--cta { border-top: none; padding-top: clamp(1rem, 2.6vh, 1.5rem); }

/* --------------------------------------------------------------------------
   Sin pin: pantalla angosta o movimiento reducido.
   La sección vuelve a su altura normal y la pista pasa a swipe nativo con
   snap. Se reusan las mismas fichas: no hay un segundo árbol de contenido
   que mantener en sincronía. El módulo 14 lee la `position` calculada del
   pin y se apaga solo.
   -------------------------------------------------------------------------- */
@media (max-width: 1024px), (prefers-reduced-motion: reduce) {
  .fases {
    --fases-card: min(82vw, 400px);
    --fases-inset: var(--gutter);
    height: auto;
    /* Mismo patrón de variables que .section en base.css, para que la regla de
       ritmo entre secciones del mismo tema también valga acá cuando el pin se
       apaga y la sección vuelve a tener aire propio. */
    padding-block: var(--section-pad-top, var(--section-pad))
                   var(--section-pad-bottom, var(--section-pad));
  }
  .fases__track {
    height: auto;
    padding-right: var(--fases-inset);
  }
  .fases__pin {
    position: static;
    height: auto;
    overflow: visible;
    padding-top: 0;
    padding-bottom: 0;
  }
  .fases__rail { display: none; }

  .fases__viewport {
    display: block;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    scrollbar-width: thin;
    margin-top: clamp(2rem, 1.5rem + 2vw, 3rem);
    padding-bottom: var(--sp-4);
  }
  .fase {
    scroll-snap-align: center;
    min-height: min(70vh, 560px);
  }
  /* Sin pin no hay ficha "enfocada": van todas en su estado pleno. Una sola
     linea, porque --focus hereda y de el salen fondo, borde, sombra, escala,
     la regla de acento y el numeral. */
  .fase__card { --focus: 1; }

  .fases__hint {
    max-width: var(--container);
    margin-inline: auto;
    padding-inline: var(--gutter);
    display: block;
    margin-top: var(--sp-5);
    font-size: var(--fs-xs);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    /* --ink-soft y no --ink-faint: medido a 390 daba 2,26:1. Es la TERCERA vez
       que este token no llega a AA (placeholder del formulario en la 7ª,
       .servicios__total y esto en la 16ª). --ink-faint sirve para dibujo, no
       para texto. */
    color: var(--ink-soft);
  }
}

/* ==========================================================================
   08.5 · CASOS (páginas SEO de servicio) ░░
   .grid y .hero__ctas son primitivas sin margen propio — cuelgan sueltos de
   .section__head igual que el chip-row del hero (ver components.css § 5),
   así que sin esto el grid queda pegado al lead y el botón pegado al grid.
   ========================================================================== */
#casos .grid { margin-top: clamp(2.5rem, 1.8rem + 2.4vw, 3.5rem); }
#casos .hero__ctas { margin-top: var(--sp-8); }

/* ==========================================================================
   09 · FORMULARIO ░░ — la ficha
   Anatomía adaptada del formulario de brumalab.agency (HTML y CSS reales
   descargados, no una impresión visual). Lo que lo hace funcionar NO es el
   estilo de los campos: es la caja. Una sola losa con overflow:hidden,
   grilla con gap CERO y filetes que corren de canto a canto. Eso convierte
   una pila de inputs en una planilla.
   ========================================================================== */

/* La sección se hunde para que el panel se levante. Fórmula simétrica a
   propósito (misma lección que --proy-glass): en claro da panel #EAE3D6
   sobre #E5DDCD, en oscuro #1D1C18 sobre #161511 — en los dos el panel
   queda MÁS CLARO que el suelo, que es la relación de la referencia
   (#fff sobre #f8f7f3). Cambiar el tema sigue siendo cambiar el data-theme. */
.formulario { background: var(--bg-sunk); }

/* --------------------------------------------------------------------------
   Cabecera a dos columnas
   -------------------------------------------------------------------------- */
.formulario__head {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(1.5rem, 1rem + 2vw, 3rem);
  align-items: end;
}
/* El max-width en ch va en el elemento que TIENE la fuente, nunca en el
   envoltorio: ch se mide contra la fuente propia. */
.formulario__head .h2 { margin-top: var(--sp-4); max-width: 19ch; }
.formulario__head .lead { max-width: 46ch; }

/* --------------------------------------------------------------------------
   La tira de ganancias — el libro ABIERTO.
   Comparte vocabulario de celda con la ficha de abajo a propósito: las dos
   bandas se leen como un sistema, no como dos componentes sueltos.
   -------------------------------------------------------------------------- */
.formulario__gains {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0;
  margin-top: clamp(3rem, 2rem + 3vw, 4.5rem);
  border-top: 1px solid var(--line-strong);
}
.gain { padding: var(--sp-6) var(--sp-6) var(--sp-8); }
.gain:first-child { padding-left: 0; }
.gain + .gain { border-left: 1px solid var(--line); }

/* Familia A. El título de esta fila es --fs-h4 (17→19) y no --fs-h3 como el de
   servicios, así que el numeral baja a 22→30 para conservar la MISMA proporción
   contra su título, que es lo que hace que las dos secciones se lean iguales
   aunque los números no coincidan. */
.gain__num { --num-paso-fs: clamp(1.375rem, 1.15rem + 0.9vw, 1.875rem); }
.gain__title {
  font-family: var(--font-ui); font-weight: 700;
  font-size: var(--fs-h4); line-height: var(--lh-snug);
  color: var(--ink);
  margin-top: var(--sp-3);
}
.gain__line {
  margin-top: var(--sp-2);
  font-size: var(--fs-sm); color: var(--ink-mute);
  line-height: var(--lh-relaxed); text-wrap: pretty;
}

/* --------------------------------------------------------------------------
   La ficha — el mismo libro, CERRADO.
   No usa .card ni .card--glass: esas primitivas traen un
   transform: translateY(-4px) en :hover que levantaba el formulario entero
   mientras se escribía dentro.
   -------------------------------------------------------------------------- */
.ficha {
  --ficha-field-h: clamp(3.5rem, 3rem + 1.6vw, 4.5rem);
  margin-top: clamp(2rem, 1.5rem + 2vw, 3rem);
  background: var(--bg);
  border-radius: var(--radius-lg);
  overflow: hidden;                    /* la clave: recorta las celdas */
  box-shadow: inset 0 0 0 1px var(--line-strong), var(--shadow-lift);
}

.ficha__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0;                              /* las celdas se tocan; solo filetes */
}
.ficha__col { display: flex; flex-direction: column; }
.ficha__col + .ficha__col { border-left: 1px solid var(--line); }
.ficha__cell + .ficha__cell { border-top: 1px solid var(--line); }
.ficha__cell--grow { flex: 1; }

/* La celda: etiqueta arriba en su propio bloque, campo alto y sin caja
   debajo. La altura es la mitad del efecto. */
.ficha__cell { position: relative; }
.ficha__cell::before {
  content: ""; position: absolute; inset: 0;
  background: var(--ink); opacity: 0;
  transition: opacity var(--dur) var(--ease-out);
  pointer-events: none;
}
.ficha__cell:hover::before,
.ficha__cell:focus-within::before { opacity: .025; }
.ficha__cell > * { position: relative; z-index: 1; }

.ficha__label-wrap {
  position: relative;
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--sp-4);
  padding: var(--sp-8) var(--sp-8) var(--sp-1);
}
.field.ficha__cell label {
  color: var(--ink);
  margin-bottom: 0;
  min-width: 0;                        /* que se achique la etiqueta, no el error */
}
.ficha__cell .req { color: var(--accent-2); }
.ficha__cell .opt {
  color: var(--ink-soft); font-weight: 500;
  letter-spacing: 0; text-transform: none;
}

.field.ficha__cell :is(input, select, textarea) {
  width: 100%;
  height: var(--ficha-field-h);
  padding: 0 var(--sp-8);
  background: transparent;
  border: none;                        /* el filete lo pone la celda, no el campo */
  border-radius: 0;
  font-family: var(--font-body);
  font-size: var(--fs-body);
  color: var(--ink);
}
.field.ficha__cell textarea {
  height: auto; flex: 1; min-height: 9rem;
  padding-block: var(--sp-4);
  resize: none;
  line-height: var(--lh-relaxed);
}
/* --ink-faint medía 2.26:1 contra el relleno del panel: ilegible, y estos
   placeholders llevan el formato esperado ("11 5555 5555 o vos@correo.com"),
   así que son información. Con --ink-soft dan 4.96:1 y siguen despegados del
   texto escrito, que va en --ink pleno a 13.36:1. */
.field.ficha__cell ::placeholder { color: var(--ink-soft); }

/* El select nativo suma su propio padding interno sobre el nuestro: su texto
   caía 4px a la derecha del de los inputs y rompía la alineación de la
   planilla, que es lo único que sostiene la lectura de celda. appearance:none
   lo empareja y de paso saca la flecha del sistema, que desentonaba. */
.field.ficha__cell select {
  appearance: none; -webkit-appearance: none;
  padding-right: calc(var(--sp-8) + var(--sp-5));
}
/* La flecha. Era un clip-path: polygon(0 0, 100% 0, 50% 100%) sobre una caja de
   10×6 px, y ahí es donde peor se porta: clip-path recorta con una máscara de
   cobertura que Chromium no antialiasea como antialiasea un path SVG, así que a
   ese tamaño la hipotenusa salía escalonada — sobre todo a DPR 1, donde 6px de
   alto son 6 filas y el triángulo tiene que resolver la diagonal en seis pasos.

   Ahora es un SVG en data-URI con la punta ya dibujada como <path>: lo rasteriza
   el motor de SVG, con su propio antialias, y queda limpio a cualquier DPR.
   El color sigue el tema porque el mask toma el background, que es --ink-mute;
   el SVG sólo aporta la FORMA. (Un data-URI con `fill` fijo habría clavado el
   color y roto el volteo claro/oscuro.) */
.ficha__cell:has(select)::after {
  content: ""; position: absolute; z-index: 2;
  right: var(--sp-8);
  bottom: calc(var(--ficha-field-h) / 2 - 3px);
  width: 10px; height: 6px;
  background: var(--ink-mute);
  --flecha: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M0 0h10L5 6Z' fill='%23000'/%3E%3C/svg%3E");
  -webkit-mask-image: var(--flecha);
          mask-image: var(--flecha);
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  pointer-events: none;
}

/* El error se muda al renglón de la etiqueta. En una celda de altura fija,
   debajo del campo desbordaría; acá aparece justo donde está mirando el ojo.
   Va ABSOLUTO, no como hermano flex: .field__error trae overflow:hidden, y en
   un flex con align-items:baseline una caja con overflow propio alinea por su
   borde inferior en vez de por su línea de texto — eso estiraba la celda 1-2px
   al aparecer el error y desalineaba las dos columnas de la planilla. Fuera de
   flujo el problema no puede existir. */
.ficha__label-wrap .field__error {
  position: absolute; right: var(--sp-8); bottom: var(--sp-1);
  margin-top: 0; max-height: none; overflow: visible;
  white-space: nowrap;
}
/* La celda inválida se tiñe de cobre y su subrayado se dibuja en cobre. */
.field.ficha__cell.is-invalid::before { background: var(--accent-2); opacity: .045; }
.field.ficha__cell.is-invalid .field__underline {
  background: var(--accent-2); transform: scaleX(1);
}

/* --------------------------------------------------------------------------
   Zona de acciones. La referencia pone acá un checkbox redondo de
   consentimiento legal; no se replica como checkbox porque sería un sexto
   campo obligatorio y no hay página de privacidad a la que atarlo (el link
   del footer es href="#"). En su lugar absorbe el trabajo del bloque
   "Qué no es" que se sacó de la sección.
   -------------------------------------------------------------------------- */
.ficha__actions {
  display: flex; align-items: center; justify-content: space-between;
  gap: clamp(1.5rem, 1rem + 2vw, 3rem);
  padding: var(--sp-8);
  border-top: 1px solid var(--line-strong);
}
.ficha__assure { display: flex; flex-direction: column; gap: var(--sp-3); }
.assure__item {
  display: flex; align-items: baseline; gap: var(--sp-3);
  font-size: var(--fs-sm); color: var(--ink-mute);
  line-height: var(--lh-normal); text-wrap: pretty;
}
/* Punto redondo: rima con el checkbox circular de la referencia sin su costo. */
.assure__dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--accent); flex-shrink: 0;
  transform: translateY(-1px);
}
.ficha__send { display: flex; flex-direction: column; gap: var(--sp-3); }
.ficha__send .form__foot { text-align: center; }

.ficha .success { padding: clamp(3rem, 2rem + 4vw, 5rem) var(--sp-8); }
.success__text {
  max-width: 46ch; color: var(--ink-mute);
  line-height: var(--lh-relaxed); text-wrap: pretty;
}

/* --------------------------------------------------------------------------
   Responsive — mismos cortes que la referencia
   -------------------------------------------------------------------------- */
@media (max-width: 1024px) {
  .formulario__head { grid-template-columns: 1fr; align-items: start; }
  .formulario__head .h2 { max-width: 24ch; }

  .formulario__gains { grid-template-columns: 1fr 1fr; }
  .gain { padding-left: var(--sp-6); }
  .gain:nth-child(odd) { padding-left: 0; border-left: none; }
  .gain:nth-child(n+3) { border-top: 1px solid var(--line); }

  .ficha__grid { grid-template-columns: 1fr; }
  .ficha__col + .ficha__col { border-left: none; border-top: 1px solid var(--line); }
}

@media (max-width: 768px) {
  .ficha { --ficha-field-h: 3.25rem; }
  .ficha__label-wrap { padding: var(--sp-5) var(--sp-5) var(--sp-1); }
  .field.ficha__cell :is(input, select, textarea) { padding-inline: var(--sp-5); }
  .field.ficha__cell select { padding-right: calc(var(--sp-5) + var(--sp-5)); }
  .ficha__cell:has(select)::after { right: var(--sp-5); }
  .field.ficha__cell textarea { min-height: 7.5rem; }
  .ficha__actions { flex-direction: column; align-items: stretch; padding: var(--sp-5); }
  .ficha__send .btn { width: 100%; }
}

@media (max-width: 640px) {
  /* Acá no hay dos columnas que alinear, así que el error puede volver al
     flujo y bajar de renglón en vez de pisar una etiqueta larga. */
  .ficha__label-wrap { flex-wrap: wrap; }
  .ficha__label-wrap .field__error { position: static; }

  .formulario__gains { grid-template-columns: 1fr; }
  .gain { padding: var(--sp-5) 0; border-left: none; }
  .gain + .gain { border-top: 1px solid var(--line); }
}

/* ==========================================================================
   10 · QA ░░
   ========================================================================== */
.qa .accordion { margin-top: clamp(2.5rem, 1.8rem + 2.6vw, 3.5rem); }
.qa__foot {
  margin-top: clamp(2.5rem, 2rem + 2vw, 3.5rem);
  text-align: center;
  font-size: var(--fs-sm); color: var(--ink-mute);
}
.qa__foot a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }

/* ==========================================================================
   FOOTER ██ — wordmark sobredimensionado recortado por el borde
   ========================================================================== */
.footer {
  position: relative;
  overflow: hidden;
  padding: clamp(4rem, 3rem + 3vw, 6rem) 0 0;
}

/* --------------------------------------------------------------------------
   Luz que entra por la costura de arriba. Adaptado del degradé superior de la
   referencia (elipse anclada arriba al centro + un filete desenfocado).
   Va en pseudo-elementos: no entra ni un <div> nuevo.

   OJO CON LOS NÚMEROS — son MÁS FUERTES que los de la referencia a propósito,
   y bajarlos al 8% original vuelve el efecto invisible. Motivo: arriba está QA,
   que es CLARA. El ojo viene adaptado a la crema y cae a casi negro, así que
   por contraste simultáneo el resplandor se lo come la transición; encima su
   pico cae en y=0, pegado a lo más brillante de la pantalla. El pie de la
   referencia vive en una página oscura y no tiene ese salto. Medido: al 8% el
   delta era de +15 puntos de luma y NO se percibía; al 26% son +60 y se lee.

   El fundido va a rgba(..., 0) y no a `transparent`: transparent interpola por
   negro transparente y ensucia el degradé.

   Sobre negro la luz de la casa es la CREMA, no el blanco — la misma lección
   que el canto iluminado de la pila de la 04. rgba(var(--glass-rgb), α) es el
   idioma con el que components.css ya compone sus degradés: el triplete está
   declarado justamente para esto, y al ser semántico se da vuelta solo si el
   pie alguna vez pasa a tema claro.

   El overflow:hidden de arriba recorta la mitad superior del filete, y está
   bien que lo haga: la referencia lo sube media altura porque su pie es una
   tarjeta redondeada sobre fondo oscuro, pero acá arriba está QA, que es
   CLARA, y un resplandor crema desbordando hacia ella sería invisible.
   Recortado, lo que queda es luz entrando hacia adentro.
   -------------------------------------------------------------------------- */
.footer::before {
  content: ""; position: absolute; inset: 0 0 auto; height: 240px;
  background: radial-gradient(65% 240px at 50% 0%,
                              rgba(var(--glass-rgb), .26),
                              rgba(var(--glass-rgb), 0));
  pointer-events: none; z-index: 0;
}
.footer::after {
  content: ""; position: absolute; top: 0; left: 50%;
  width: 33%; height: 3px; transform: translateX(-50%);
  background: rgba(var(--glass-rgb), .48);
  border-radius: var(--radius-pill);
  filter: blur(12px);
  pointer-events: none; z-index: 0;
}
/* El contenido, por encima de los dos. */
.footer > .container,
.footer__bleed { position: relative; z-index: 1; }

.footer__grid { grid-template-columns: 1.4fr 1fr 1fr; }
/* SVG por <use> contra el sprite del <body>, igual que el nav. aspect-ratio
   explicito para que no dependa del dimensionado intrinseco del viewBox:
   30 × (1235/266) = 139,3px, contra los 140 que media el PNG. */
.footer__logo { height: 30px; width: auto; aspect-ratio: 1235 / 266; }
.footer__tag {
  font-family: var(--font-display);
  font-size: clamp(1.35rem, 1.1rem + 1vw, 1.75rem);
  line-height: 1.3;
  color: var(--ink);
  max-width: 22ch;
  margin-top: var(--sp-5);
}
.footer__heading {
  font-family: var(--font-ui); font-weight: 600; font-size: var(--fs-xs);
  letter-spacing: var(--track-label); text-transform: uppercase;
  /* --ink-mute y no --ink-soft: estas dos etiquetas caen a ~90px del borde,
     o sea dentro de la parte más encendida del resplandor de arriba, y ahí el
     fondo real sube de 27 a 59 de luma. --ink-soft está calibrado contra --bg,
     así que en ese punto se cae a 3.94:1 — debajo de AA. Es el mismo caso que
     .proyecto__card sobre la card levantada. Medido acá: 5.61:1.
     Si alguna vez se baja la intensidad del ::before, esto puede volver. */
  color: var(--ink-mute);
}
.footer__links { margin-top: var(--sp-5); display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; }
.footer__links a, .footer__links span {
  font-size: var(--fs-sm); color: var(--ink-mute);
  transition: color var(--dur) var(--ease-out);
}
.footer__links a:hover { color: var(--ink); }
.footer__pending { font-style: italic; opacity: .8; }

.footer__bottom {
  margin-top: clamp(3rem, 2rem + 3vw, 4.5rem);
  border-top: 1px solid var(--line);
  padding-block: var(--sp-6);
  display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--sp-2);
  font-size: var(--fs-xs);
  /* --ink-soft está calibrado contra el --bg de la sección, pero el muro de
     plancha aclara el suelo del pie hasta rgb(48,47,42) y ahí cae a 4.33:1,
     debajo de AA (medido contra el fondo REAL del render). Es la CUARTA vez
     que aparece este patrón: ya pasó en .proyecto__card (5ª pasada),
     .footer__heading (8ª) y .testimonio__role (11ª). Regla de la casa:
     --ink-soft no sobrevive encima de un muro de plancha.
     Si alguna vez se baja la opacidad del muro, revisar esto de nuevo. */
  color: var(--ink-mute);
}
.footer__bottom a:hover { color: var(--ink); }

/* Wordmark bleed */
.footer__bleed {
  margin-top: var(--sp-6);
  height: clamp(70px, 11vw, 150px);
  overflow: hidden;
  pointer-events: none;
  user-select: none;
}
.footer__bleed span {
  position: absolute; left: 50%; top: 0;
  transform: translateX(-50%);
  font-family: var(--font-ui); font-weight: 800;
  font-size: clamp(5rem, 17vw, 15rem);
  line-height: .78;
  letter-spacing: -0.045em;
  color: var(--ink);
  opacity: .07;
  white-space: nowrap;
}

@media (max-width: 640px) {
  .footer__grid { grid-template-columns: 1fr; gap: var(--sp-10); }
}

/* ==========================================================================
   SCROLL-DRIVEN — continuos y reversibles. Nativo, sin librería.
   Los reveals NO viven acá: se disparan una sola vez (ver base.css).
   ========================================================================== */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {

    /* Progreso de scroll en el nav */
    .nav__progress {
      animation: obs-draw-x linear both;
      animation-timeline: scroll(root block);
    }

    /* Salida del hero: el contenido sube y se desvanece… */
    .hero__inner {
      animation: obs-hero-exit linear both;
      animation-timeline: scroll(root block);
      animation-range: 0 92vh;
    }
    @keyframes obs-hero-exit {
      0%, 45% { opacity: 1; transform: translate3d(0, 0, 0); }
      100%    { opacity: 0; transform: translate3d(0, -48px, 0); }
    }

    /* …mientras el fondo escala apenas. Da profundidad sin pinnear nada. */
    .hero__bg {
      animation: obs-hero-zoom linear both;
      animation-timeline: scroll(root block);
      animation-range: 0 100vh;
    }
    @keyframes obs-hero-zoom { to { transform: scale(1.09); } }
  }
}

/* ==========================================================================
   POLÍTICA DE PRIVACIDAD ██ — artículo de una sola columna, ancho de lectura
   ========================================================================== */
.politica__meta { color: var(--ink-mute); font-size: var(--fs-sm); margin-top: var(--sp-5); }
.politica { display: flex; flex-direction: column; gap: var(--sp-10); }
.politica__item { display: flex; flex-direction: column; gap: var(--sp-4); }
.politica__item p { color: var(--ink-soft); }
.politica a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
