/* ==========================================================================
   OBSCURA · COMPONENTES
   Todo se compone de tokens semánticos. Cero colores crudos.
   ========================================================================== */

/* ==========================================================================
   1 · VIDRIO — la primitiva de la marca
   Una sola definición para los dos temas: --glass-rgb es el triplete que
   arma el borde degradado, así que no hay que duplicar nada al invertir.

   EL GLIMPSE (frente iv, 16ª pasada) — luz de canto, NO barrido diagonal.
   El hairline de abajo es la BASE aprobada y no se toca. Encima viaja un arco
   especular cónico que rota para enfrentar al cursor: --glass-spec.

   Es ADITIVO a propósito, y el motivo es geométrico, no de prudencia.
   Reemplazar el linear por un cónico cambia el estado de REPOSO: en un
   elemento de 14:1 como la isla del nav, un cónico ilumina una mancha en el
   medio del canto y apaga las esquinas, en vez de dejar el canto entero
   parejo. Y como la isla vive contra el borde SUPERIOR del viewport, el
   cursor está casi siempre POR DEBAJO — un cónico puro dejaría el canto de
   arriba (el que aprobó la 15ª pasada) apagado de forma permanente.
   El vidrio real tiene las dos cosas: canto base Y especular móvil.
   ========================================================================== */

/* LAS DOS HEREDAN A PROPÓSITO, y no es un detalle de estilo.
   El cónico vive en ::before, que es HIJO en el árbol de herencia, y JS sólo
   puede escribir sobre el ELEMENTO. Con inherits: false el pseudo resuelve al
   initial-value y el arco queda clavado en 180deg SIN DAR NINGÚN ERROR.
   Es exactamente el bug de --focus de la 6ª pasada (sections.css § 08 FASES),
   que mató dos efectos en silencio porque los hijos leían el initial-value. */
@property --glass-angle { syntax: "<angle>";  inherits: true; initial-value: 180deg; }
@property --glass-press { syntax: "<number>"; inherits: true; initial-value: 0; }
/* --glass-lit es el interruptor de LUZ, y hace falta por una razón concreta:
   con la capa cónica encendida pero sin que el puntero se haya movido nunca,
   el lóbulo se dibuja en el ángulo inicial (180deg = abajo) y eso YA cambia el
   reposo aprobado. A lit: 0 el cónico aporta alfa cero y el canto es idéntico
   al de hoy. Sube a 1 con el primer pointermove y vuelve a 0 cuando el puntero
   se va de la ventana: el especular existe mientras hay fuente de luz.
   Se escribe UNA sola vez, sobre <html>: hereda a las doce superficies. */
@property --glass-lit   { syntax: "<number>"; inherits: true; initial-value: 0; }

/* La capa especular, apagada por defecto. La enciende html.obs-glimpse, que
   pone el módulo 6 SÓLO si hay puntero fino, se permite movimiento y existe
   @property. Sin esa clase el canto es, byte por byte, el hairline de hoy:
   sin JS, en táctil, en reduced-motion y en un navegador sin @property. */
:root { --glass-spec: none; }
/* La transición de --glass-lit va acá, en <html>, porque acá el cambio de
   valor es DIRECTO (lo escribe el módulo 6) y no heredado. */
html { transition: --glass-lit var(--dur-slow) var(--ease-out); }

/* ⚠ ESTA REGLA VA EN EL ::before Y NO EN :root, Y NO ES UN DETALLE DE ANIDADO.
   Un var() adentro de una custom property se sustituye en el elemento donde la
   propiedad se DECLARA, no donde se usa. Declarada en :root, --glass-spec se
   horneaba con el --glass-angle de <html> (el initial-value, 180deg) y bajaba
   heredada como texto fijo: el ::before recibía el ángulo correcto por
   herencia y el gradiente lo ignoraba. El arco no rotaba nunca, sin dar un
   solo error — y la prueba de píxeles daba verde igual, porque --glass-lit
   estaba horneado en el mismo lugar y ése sí cambiaba.
   Declarada acá, los tres var() de adentro resuelven contra el propio pseudo,
   que es quien hereda lo que JS escribe en el elemento.
   Es prima hermana de la trampa de inherits: false de arriba. */
/* ⚠ LA PILA DE PROYECTOS QUEDA AFUERA, Y ESTÁ MEDIDO.
   A/B con la CPU 4× más lenta, mismo recorrido de puntero + scroll, en la
   misma sesión: con las doce superficies el raster pasa de 224 a 1946 ms;
   con todo MENOS las cinco cards de proyectos, de 224 a 259. Las cinco cards
   pagan el 99% del costo del efecto (1773 de 1794 ms).
   No es su tamaño: los testimonios miden casi lo mismo y salen gratis. Es que
   .proyecto__card está en PILA STICKY —cinco a la vez en pantalla, cada una
   sobre backdrop-filter: blur(22px) y con un transform que el módulo 13 le
   reescribe por cuadro—, así que repintar su ::before obliga a recomponer
   cinco regiones desenfocadas juntas.
   Además ya llevan tres cosas encima (escala del scrub, canto iluminado
   propio y sombra): un especular rotando ahí era el cuarto efecto. */
html.obs-glimpse :is(.glass, .btn--glass, .nav__shell):not(.proyecto__card)::after {
  --glass-spec: conic-gradient(
    from var(--glass-angle),
    /* El lóbulo principal se centra en 0%, que con `from` cae exacto en la
       dirección del cursor. Va con --spec-rgb y NO con --glass-rgb: en tema
       claro ese triplete es la tinta oscura y el "brillo" oscurecería.

       ALFA AL TOPE (1), y no es exceso: se eligió sobre render comparando
       .42 / .75 / 1.0 con recorte apretado a 2x. A .42 —el valor con el que
       arrancó esta pasada— la isla es INDISTINGUIBLE de la isla sin efecto.
       Es el tercer caso del proyecto donde el número de partida era corto:
       el degradé del pie necesitó .26 contra el .08 de la referencia (8ª), y
       la plancha 15-22% contra el 3-6% de la dirección (10ª/11ª).
       Contra el canto base, que va a .50, el lóbulo es el DOBLE de brillo:
       eso es lo que se lee como luz raspando el canto. */
    rgba(var(--spec-rgb), var(--glass-lit))  0%,
    rgba(var(--spec-rgb), 0)   calc(15% - 7.5% * var(--glass-press)),
    /* Contra-lóbulo del lado opuesto: la luz entra por un canto y sale por el
       otro, que es lo que hace el vidrio de verdad. Débil a propósito — si
       compite con el principal, lee como dos luces y no como una. */
    rgba(var(--spec-rgb), 0)   calc(38% + 6% * var(--glass-press)),
    rgba(var(--spec-rgb), calc((.30 - .12 * var(--glass-press)) * var(--glass-lit)))  50%,
    rgba(var(--spec-rgb), 0)   calc(62% - 6% * var(--glass-press)),
    rgba(var(--spec-rgb), 0)   calc(85% + 7.5% * var(--glass-press)),
    rgba(var(--spec-rgb), var(--glass-lit)) 100%
  );
}

.glass {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  border: none;
  border-radius: var(--radius-lg);
  background: var(--glass-bg);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.35);
  backdrop-filter: blur(var(--glass-blur)) saturate(1.35);
  box-shadow: inset 0 1px 1px var(--glass-inner), var(--shadow-card);
  transition:
    background var(--dur) var(--ease-out),
    box-shadow var(--dur) var(--ease-out),
    transform var(--dur) var(--ease-out);
}

/* Hairline degradada: brillante arriba y abajo, invisible al medio.
   Es lo que hace que lea como un canto de vidrio y no como un borde.

   Los porcentajes llevan --glass-press: al apretar, las bandas se comprimen
   hacia los cantos. A press: 0 los valores son EXACTAMENTE los de siempre
   (22 / 42 / 58 / 78), así que el reposo no cambió.

   La transición del press va acá, en el PSEUDO, y no en la shorthand del
   elemento: .btn--glass hereda la transition compuesta de .btn (§ 2) y
   agregarle una propiedad obligaría a repetir esa lista entera en dos
   lugares. El pseudo recibe --glass-press por herencia, y una transición
   corre igual sobre un cambio de valor computado que llega heredado. */
/* ⚠ EL ARO LO DIBUJA box-shadow, NO UN DEGRADADO RECORTADO. Y EL MOTIVO ES
   DE MUESTREO, no de estilo — es el arreglo de la 17ª pasada.

   Hasta acá el canto se sacaba con la receta clásica: padding 1,2px, el
   degradado de fondo y dos máscaras restadas con mask-composite: exclude.
   Esa resta ES el bug. `exclude` da a·(1−b) + b·(1−a), y sobre la CURVA las
   dos rampas de antialias —la del borde exterior y la del interior, separadas
   apenas 1,2px— se pisan: donde ambas valen ~.5 el resultado se desploma en
   vez de dar 1. En el tramo recto no pasa nada (las rampas son verticales y no
   se cruzan), pero en las esquinas y en los extremos de las cápsulas la línea
   sale PUNTEADA, con grosor irregular. Eso es lo que se leía como "borde
   pixelado", y a DPR 1 es donde más se nota porque 1,2px CSS son 1,2px físicos.
   Medido a 12× sobre el CTA del nav: con exclude el arco se corta; con
   box-shadow es continuo y parejo.

   box-shadow: inset lo dibuja Skia como UN solo trazo sobre el rounded-rect,
   con su antialias propio, así que sigue el border-radius sin escalones.
   El degradado vertical (brillante arriba y abajo, invisible al medio) NO se
   pierde: pasa a ser la MÁSCARA. El color del aro se fija en el alfa máximo
   (.50) y la máscara lleva la rampa RELATIVA — 1 / .32 / 0 — o sea .50 y
   .16 exactos, los mismos números de siempre. Y como la máscara es un
   degradado vertical sin forma redondeada, no hay dos bordes que restar.

   El --glass-press sigue igual: los mismos calc() en los mismos porcentajes,
   sólo que ahora viven en la máscara. A press: 0 son 22/42/58/78 como siempre.
   El cónico especular del glimpse se mudó a ::after (§ 1 bis). */
.glass::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) calc(22% - 10% * var(--glass-press)),
      transparent        calc(42% - 12% * var(--glass-press)),
      transparent        calc(58% + 12% * var(--glass-press)),
      rgba(0, 0, 0, .32) calc(78% + 10% * var(--glass-press)),
      #000             100%
    );
          mask: linear-gradient(
      180deg,
      #000              0%,
      rgba(0, 0, 0, .32) calc(22% - 10% * var(--glass-press)),
      transparent        calc(42% - 12% * var(--glass-press)),
      transparent        calc(58% + 12% * var(--glass-press)),
      rgba(0, 0, 0, .32) calc(78% + 10% * var(--glass-press)),
      #000             100%
    );
  pointer-events: none;
  transition:
    opacity var(--dur) var(--ease-out),
    --glass-press var(--dur) var(--ease-out);
}

/* El brillo se comprime al apretar: el lóbulo pasa de 15% (54°) a 7,5% (27°)
   y el contra-lóbulo baja, o sea que la luz se concentra de un solo lado. NO
   sube el alfa porque ya está en el tope — comprimir ES el efecto.
   Ese medio segundo es lo que hace que el material se sienta real.
   ⚠ :active PROPAGA A LOS ANCESTROS: apretar el CTA «Escribime» lo pone
   también en .nav__shell y en <body>. Que la isla NO se comprima junto con
   su propio botón sale gratis porque .nav__shell no es .glass — es copia a
   mano de la misma técnica (sections.css § 00). Si algún día se le pone la
   clase, hay que excluirla de esta regla. */
.glass:active { --glass-press: 1; }

/* ==========================================================================
   1 bis · LA CAPA ESPECULAR DEL GLIMPSE — ahora en ::after
   El aro base se mudó a box-shadow (§ 1) y ahí el cónico no entra: box-shadow
   no acepta degradados. Así que el especular se queda con la receta de
   siempre —banda de 1,2px recortada con mask-composite— pero en su PROPIO
   pseudo. Que ahí siga habiendo microdentado no importa: es un resplandor sin
   borde duro, de alfa baja y en movimiento; lo que se leía como "pixelado"
   era el aro base, que ahora es un trazo limpio.

   SÓLO EXISTE BAJO html.obs-glimpse, y eso es deliberado: sin puntero fino,
   con reduced-motion o sin @property no se crea ni el pseudo, y el canto es
   byte por byte el de § 1. Las cards de proyectos siguen excluidas por lo
   medido arriba (pagan el 99% del costo del efecto).
   ========================================================================== */
html.obs-glimpse :is(.glass, .btn--glass, .nav__shell):not(.proyecto__card)::after {
  content: "";
  position: absolute; inset: 0; z-index: 2;
  border-radius: inherit;
  padding: 1.2px;
  background: var(--glass-spec);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  pointer-events: none;
  transition:
    opacity var(--dur) var(--ease-out),
    --glass-press var(--dur) var(--ease-out);
}

/* El contenido siempre por encima del resplandor, por debajo de la hairline */
.glass > * { position: relative; z-index: 1; }

/* En pantallas chicas el backdrop-filter es caro y se nota poco:
   cae a un tinte plano. */
@media (max-width: 640px) {
  .glass {
    -webkit-backdrop-filter: none;
            backdrop-filter: none;
    background: color-mix(in srgb, var(--bg-elev) 92%, var(--ink));
  }
}

/* ==========================================================================
   2 · BOTONES
   ========================================================================== */
.btn {
  --mag-x: 0px; --mag-y: 0px;
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--sp-3);
  min-height: 48px;                 /* objetivo táctil ≥ 44px (HIG / WCAG 2.2) */
  padding: 1rem 2.25rem;
  border-radius: var(--radius-pill);
  font-family: var(--font-ui);
  font-weight: 600;
  font-size: var(--fs-body);
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  transform: translate3d(var(--mag-x), var(--mag-y), 0);
  transition:
    transform var(--dur) var(--ease-spring),
    background var(--dur) var(--ease-out),
    color var(--dur) var(--ease-out),
    border-color var(--dur) var(--ease-out),
    box-shadow var(--dur) var(--ease-out);
}
.btn:active { transform: translate3d(var(--mag-x), var(--mag-y), 0) scale(.975); }

/* ⚠ EL ÚNICO DESBORDE HORIZONTAL DEL SITIO SALÍA DE ACÁ (45ª pasada), y no se
   veía porque nadie había probado abajo de 390 px.

   `white-space: nowrap` (arriba) más 2,25rem de padding a cada lado son 72 px
   de marco alrededor de un texto que TIENE PROHIBIDO PARTIRSE. Con una etiqueta
   larga eso no entra en un teléfono chico y el botón se sale de la ventana:
   medido con anchos3.mjs, «Ver las cuatro fases del proyecto» (nosotros § Método)
   sobresalía 52 px a 320 y 12 px a 360, que es exactamente el desborde que
   acusaba el documento. La home hacía lo mismo con sus CTA: 60 px y 20 px.

   El nowrap tiene su motivo —una píldora partida en dos renglones se ve mal— y
   por eso NO se saca en general: se saca sólo donde la alternativa es peor.
   Debajo de 480 px el botón puede partirse, y sólo se parte si no entra: a
   `white-space: normal` un texto que entra sigue en un renglón. O sea que esto
   no cambia ningún botón que hoy esté bien.

   Las otras tres declaraciones son lo que hace que partirse no se vea como un
   accidente: `line-height: 1` viene de suponer un solo renglón y a dos deja las
   letras tocándose; `text-wrap: balance` reparte los renglones en vez de dejar
   una palabra huérfana abajo; y `min-width: 0` en la etiqueta es lo que le
   permite encogerse — un ítem flex no baja de su min-content sin eso, así que
   sin esta línea el texto seguiría empujando la caja aunque pudiera partirse. */
@media (max-width: 480px) {
  .btn {
    white-space: normal;
    text-wrap: balance;
    line-height: 1.25;
    padding-inline: 1.5rem;
  }
  .btn__label { min-width: 0; }
}

/* Flecha que avanza en hover */
.btn__arrow {
  display: inline-block;
  transition: transform var(--dur) var(--ease-out);
}
.btn:hover .btn__arrow { transform: translateX(4px); }

/* --- Vidrio. Hoy vive SOLO en el CTA del nav: los dos del hero son
       .btn--accent y .btn--ghost. (El comentario viejo decía "CTAs del hero"
       y estaba desactualizado desde antes de la 15ª pasada.) --- */
.btn--glass {
  isolation: isolate;
  overflow: hidden;
  color: var(--ink);
  background: var(--glass-bg);
  -webkit-backdrop-filter: blur(8px) saturate(1.3);
          backdrop-filter: blur(8px) saturate(1.3);
  box-shadow: inset 0 1px 1px var(--glass-inner);
}
/* Mismo canto que .glass::before (§ 1), con sus propios alfas. Lleva la capa
   especular del glimpse y el press en los porcentajes; a press: 0 los valores
   son exactamente los de siempre (25 / 45 / 58 / 78). */
/* Aro por box-shadow, igual que .glass::before — mismo motivo de muestreo y
   ahí está explicado. Acá es donde MÁS se veía: el radio es de cápsula, así
   que la curva es continua de punta a punta y la resta de máscaras cortaba el
   arco entero. Alfa máxima .55 en el trazo y rampa relativa 1 / .327 / 0 en
   la máscara: .55 y .18 exactos, los de siempre. Press idéntico (25/45/58/78
   a press: 0). El especular vive en ::after (§ 1 bis). */
.btn--glass::before {
  content: "";
  position: absolute; inset: 0; z-index: 2;
  border-radius: inherit;
  box-shadow: inset 0 0 0 var(--hairline) rgba(var(--glass-rgb), .55);
  -webkit-mask: linear-gradient(
      180deg,
      #000               0%,
      rgba(0, 0, 0, .327) calc(25% - 11% * var(--glass-press)),
      transparent         calc(45% - 13% * var(--glass-press)),
      transparent         calc(58% + 13% * var(--glass-press)),
      rgba(0, 0, 0, .327) calc(78% + 11% * var(--glass-press)),
      #000              100%
    );
          mask: linear-gradient(
      180deg,
      #000               0%,
      rgba(0, 0, 0, .327) calc(25% - 11% * var(--glass-press)),
      transparent         calc(45% - 13% * var(--glass-press)),
      transparent         calc(58% + 13% * var(--glass-press)),
      rgba(0, 0, 0, .327) calc(78% + 11% * var(--glass-press)),
      #000              100%
    );
  pointer-events: none;
  transition: --glass-press var(--dur) var(--ease-out);
}
.btn--glass > * { position: relative; z-index: 1; }
.btn--glass:hover { background: var(--glass-bg-hover); }
/* El botón SÍ se comprime al apretarlo: es lo único de la página que se
   aprieta de verdad. La isla que lo contiene no, por lo dicho en § 1. */
.btn--glass:active { --glass-press: 1; }

/* Fallback movil, copiado de .glass (§ 1). Hasta la 15a pasada este boton no
   existia bajo 640px (.nav__cta iba display:none), asi que su blur permanente
   no costaba nada. Ahora el CTA del header SI existe en movil: es un elemento
   FIJO que compone en cada cuadro de scroll, en el dispositivo mas debil.
   Sin esto, el arreglo de la fuga de conversion se paga en cuadros perdidos. */
@media (max-width: 640px) {
  .btn--glass {
    -webkit-backdrop-filter: none;
            backdrop-filter: none;
    background: color-mix(in srgb, var(--bg-elev) 92%, var(--ink));
  }
  .btn--glass:hover { background: color-mix(in srgb, var(--bg-elev) 86%, var(--ink)); }
}

/* --- Acento sólido (la CTA más importante de la página) --- */
.btn--accent {
  overflow: hidden;
  color: var(--accent-ink);
  background: var(--accent);
  box-shadow: 0 1px 2px rgba(0,0,0,.10), 0 10px 28px -14px var(--accent-glow);
}
/* Relleno secundario que barre de izquierda a derecha en hover */
.btn--accent::before {
  content: "";
  position: absolute; inset: 0; z-index: 0;
  background: var(--accent-2);
  transform: scaleX(0); transform-origin: left center;
  transition: transform var(--dur-slow) var(--ease-expo);
}
.btn--accent:hover::before { transform: scaleX(1); }
.btn--accent > * { position: relative; z-index: 1; }
.btn--accent:hover { box-shadow: 0 2px 6px rgba(0,0,0,.14), 0 16px 40px -16px var(--accent-glow); }

/* --- Fantasma / contorno --- */
.btn--ghost {
  color: var(--ink-mute);
  box-shadow: inset 0 0 0 1px var(--line-strong);
}
.btn--ghost:hover {
  color: var(--ink);
  box-shadow: inset 0 0 0 1px var(--ink-faint);
  background: var(--glass-bg);
}

.btn--block { width: 100%; }
.btn--sm { min-height: 40px; padding: .7rem 1.4rem; font-size: var(--fs-sm); }

/* ==========================================================================
   3 · CARDS
   ========================================================================== */
.card {
  position: relative;
  isolation: isolate;
  display: flex; flex-direction: column;
  padding: clamp(1.5rem, 1.1rem + 1.2vw, 2rem);
  border-radius: var(--radius-lg);
  background: var(--bg-elev);
  box-shadow: inset 0 0 0 1px var(--line);
  transition:
    transform var(--dur-slow) var(--ease-out),
    box-shadow var(--dur-slow) var(--ease-out),
    background var(--dur-slow) var(--ease-out);
}
.card:hover {
  transform: translateY(-4px);
  box-shadow: inset 0 0 0 1px var(--line-strong), var(--shadow-lift);
}
.card > * { position: relative; z-index: 1; }

.card__spacer { flex: 1; min-height: var(--sp-6); }
.card .h3 { margin-top: var(--sp-5); }
.card p {
  margin-top: var(--sp-3);
  color: var(--ink-mute);
  line-height: var(--lh-relaxed);
  text-wrap: pretty;
}

/* Card de vidrio. Hoy sólo la usa 06 Servicios: la 07 dejó de tener cards
   cuando los testimonios pasaron a ser una sola .bobina (sections.css § 07). */
.card--glass {
  background: var(--glass-bg);
  box-shadow: inset 0 1px 1px var(--glass-inner), var(--shadow-card);
  padding: clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem);
}
.card--glass:hover { background: var(--glass-bg-hover); transform: translateY(-4px); }

/* Numeral fantasma al fondo de la card */
.card__ghost-num {
  position: absolute; top: clamp(.75rem, 2vw, 1.25rem); right: clamp(1rem, 2.4vw, 1.75rem);
  z-index: 0;
  font-family: var(--font-display);
  font-size: clamp(3.5rem, 2rem + 6vw, 6rem);
  line-height: 1;
  color: var(--ink);
  opacity: .07;
  pointer-events: none;
  user-select: none;
}

/* ==========================================================================
   4 · ICONOS — line-art que se dibuja al entrar en viewport
   ========================================================================== */
.icon {
  fill: none;
  stroke: var(--accent-2);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  flex-shrink: 0;
}
.icon--accent { stroke: var(--accent); }
.icon--ink    { stroke: var(--ink); }

.icon--draw path { stroke-dasharray: 120; stroke-dashoffset: 120; }
.is-visible .icon--draw path,
.icon--draw.is-visible path {
  animation: obs-draw-stroke 1.1s var(--ease-expo) both;
  animation-delay: calc(var(--reveal-delay, 0ms) + 160ms);
}
@keyframes obs-draw-stroke { to { stroke-dashoffset: 0; } }

/* Figura grande (las cards de "Qué solucionamos"). El path se normaliza con
   pathLength="1", así el dibujado no depende del largo real del trazo:
   --draw usa un dasharray de 120 fijo y se rompe con figuras de este tamaño. */
.icon--figure { stroke-width: 1.25; }
.icon--figure path { stroke-dasharray: 1; stroke-dashoffset: 1; }
.is-visible .icon--figure path {
  animation: obs-draw-stroke 1.4s var(--ease-expo) both;
  animation-delay: calc(var(--reveal-delay, 0ms) + 200ms);
}
/* Cada subtrazo entra escalonado: la figura se arma, no aparece de golpe */
.is-visible .icon--figure path:nth-child(2) { animation-delay: calc(var(--reveal-delay, 0ms) + 320ms); }
.is-visible .icon--figure path:nth-child(3) { animation-delay: calc(var(--reveal-delay, 0ms) + 440ms); }
.is-visible .icon--figure path:nth-child(4) { animation-delay: calc(var(--reveal-delay, 0ms) + 560ms); }

/* ==========================================================================
   5 · CHIPS Y TAGS
   ========================================================================== */
.chip {
  display: inline-flex; align-items: center; gap: var(--sp-2);
  padding: .4rem .8rem;
  border-radius: var(--radius-pill);
  box-shadow: inset 0 0 0 1px var(--line-strong);
  font-family: var(--font-body);
  font-size: var(--fs-xs);
  line-height: 1.2;
  color: var(--ink-mute);
  transition: color var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-out);
}
.chip--accent { color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.chip-row { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
/* Variante de cabecera: el chip-row cuelga suelto de un .section__head, sin
   el gap de flex-column que agrupa a sus hermanos ahí adentro. Sin este
   margen queda pegado al lead, y el hero__ctas de abajo (margin-top: sp-2
   nomás) igual de pegado a él — todo el bloque se ve amontonado. */
.chip-row--hero { justify-content: center; margin-top: var(--sp-6); }
.chip-row--hero + .hero__ctas { margin-top: var(--sp-6); }

.tag {
  font-family: var(--font-ui); font-weight: 600;
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* ==========================================================================
   6 · PULL-QUOTE
   ========================================================================== */
.pull-quote {
  display: flex; flex-direction: column; align-items: center;
  text-align: center; gap: var(--sp-6);
}
.pull-quote__rule {
  width: 40px; height: 2px; background: var(--accent);
  transform-origin: center;
}
.pull-quote p {
  font-family: var(--font-display);
  font-style: italic;
  font-size: clamp(1.6rem, 1rem + 2.5vw, 2.5rem);
  line-height: 1.25;
  color: var(--ink);
  max-width: 26ch;
  text-wrap: balance;
}
.pull-quote--accent p { color: var(--accent); }

/* ==========================================================================
   7 · MARQUEE (logos de clientes)
   ========================================================================== */
.marquee {
  position: relative;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
          mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
}
.marquee__track {
  display: flex;
  align-items: center;
  gap: clamp(2rem, 1rem + 3vw, 4rem);
  width: max-content;
  animation: obs-marquee 46s linear infinite;
}
@keyframes obs-marquee { to { transform: translate3d(-50%, 0, 0); } }
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .marquee__track { animation: none; }
  .marquee { -webkit-mask-image: none; mask-image: none; overflow-x: auto; }
}

/* Variante de ancho completo. Cuelga directo del <section>, fuera del
   .container: el sangrado es real, sin trucos de 100vw (que se pelean con
   la barra de scroll). Fundido más corto que el base — al ancho del
   viewport, un 10% se comía casi dos tarjetas enteras. */
.marquee--logos {
  margin-top: clamp(2.5rem, 1.8rem + 2.4vw, 3.75rem);
  -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%);
          mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%);
}
/* Tres copias del set, no dos. Con dos, el desplazamiento de -50% deja
   descubierto todo lo que exceda el ancho de UNA copia: a 1920px la copia
   medía 1878px y aparecía un hueco en cada vuelta. Con tres, la reserva
   pasa a ser el doble del set (dos copias completas), que cubre hasta
   ~3700px. Regla general: setWidth * (copias - 1) >= ancho de viewport. */
.marquee--logos .marquee__track {
  /* El separador va como margin de cada tarjeta, NO como gap del flex.
     Con gap, el track mide 24 tarjetas + 23 huecos, y un tercio de eso
     (8 tarjetas + 7,67 huecos) no coincide con el período real del set
     (8 tarjetas + 8 huecos): el loop saltaba ~7px por vuelta. Con margin
     el ancho es 24 * (tarjeta + margen) y el tercio cae exacto. */
  gap: 0;
  animation-name: obs-marquee-3;
  /* ⚠ ESTO ESCALA CON LA CANTIDAD DE TARJETAS. La duración es el tiempo de UN
     SET, así que con más tarjetas el set es más largo y, a igual duración, la
     tira se acelera en proporción. Eran 8 tarjetas a 64s; son 17, y 17/8 × 64
     da 136s — misma velocidad en píxeles por segundo que siempre. Si se suma o
     saca un logo, se recalcula acá. */
  animation-duration: 136s;   /* 17 tarjetas · recorre un set por vuelta */
}
.marquee--logos .logo-card { margin-inline-end: clamp(.75rem, .5rem + .7vw, 1.25rem); }
@keyframes obs-marquee-3 { to { transform: translate3d(-33.3333333%, 0, 0); } }

/* El corte por reduced-motion de más arriba es .marquee__track a secas y
   .marquee--logos .marquee__track le gana en especificidad: hay que
   repetirlo acá o la tira sigue moviéndose. */
@media (prefers-reduced-motion: reduce) {
  .marquee--logos .marquee__track { animation: none; }
}

/* ==========================================================================
   8 · PLACEHOLDERS — deliberadamente visibles.
   El manual de marca prohíbe logos, ratings y cifras inventadas, así que
   los huecos se marcan para que no se escapen a producción.
   ========================================================================== */
.ph {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  text-align: center;
  padding: var(--sp-4);
  border-radius: var(--radius);
  /* SIN tramado diagonal. Era un repeating-linear-gradient(-45deg, … 0 6px,
     transparent 6px 12px), o sea rampas DURAS a 45°, que es el artefacto de
     aliasing canónico de Chromium: escalones en cada franja y moiré cuando el
     bloque escala. Con 41 slots .ph en la página —los ocho LOGO_N son lo
     segundo que se ve después del hero— era una de las fuentes reales del
     "pixelado" reportado, y la única que no tenía nada que ver con el vidrio.

     Los placeholders SIGUEN siendo obvios a propósito (es lo que impide que se
     escapen a producción): la señal ahora la dan el tinte plano, el aro y las
     dos escuadras de ::before/::after. Ninguno de los tres tiene bordes
     diagonales que muestrear.

     El aro pasa de `outline: 1.5px dashed` a `box-shadow: inset` por dos
     motivos, los dos de la 17ª pasada: outline sigue la CAJA y no el
     border-radius, así que en las esquinas dibujaba fuera del radio; y el guion
     de `dashed` lo reparte el navegador sin control, con lo que en las cajas
     angostas —las .logo-card— los trazos caían a media fila. box-shadow: inset
     lo dibuja Skia como un solo trazo continuo sobre el rounded-rect.
     El grosor sale de --hairline (tokens.css § 6 bis), que ya vale 1px a DPR 1
     justamente para que el pelo no se parta en dos filas. */
  background-color: color-mix(in srgb, var(--accent-2) 5%, transparent);
  box-shadow: inset 0 0 0 var(--hairline, 1.2px)
              color-mix(in srgb, var(--accent-2) 42%, transparent);
}
/* En placeholders chicos las esquinas son ruido: .ph--plain las saca */
.ph--plain::before, .ph--plain::after { display: none; }

.ph__label {
  font-family: var(--font-ui); font-weight: 600;
  font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
  color: color-mix(in srgb, var(--accent-2) 85%, var(--ink));
}
.ph::before, .ph::after {
  content: ""; position: absolute; width: 14px; height: 14px;
  border-color: var(--accent-2); border-style: solid; border-width: 0;
}
.ph::before { top: 8px; left: 8px; border-top-width: 2px; border-left-width: 2px; }
.ph::after { bottom: 8px; right: 8px; border-bottom-width: 2px; border-right-width: 2px; }

.ph-badge {
  display: inline-flex; align-items: center; gap: var(--sp-2);
  padding: .25rem .6rem;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--accent-2) 16%, transparent);
  color: color-mix(in srgb, var(--accent-2) 90%, var(--ink));
  font-family: var(--font-ui); font-weight: 700;
  font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
}

/* ==========================================================================
   9 · CHECKLIST
   ========================================================================== */
.checklist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--sp-4); }
.checklist li { display: flex; gap: var(--sp-3); align-items: flex-start; }
.checklist li .icon { stroke: var(--accent); stroke-width: 2; margin-top: 4px; }
.checklist li span { font-size: var(--fs-lead); line-height: var(--lh-normal); color: var(--ink); }

/* ==========================================================================
   10 · ACORDEÓN — altura animada con grid-template-rows (0fr → 1fr).
   Animable de verdad, sin medir alturas en JS.
   ========================================================================== */
.accordion { display: flex; flex-direction: column; }
.accordion__item {
  border-top: 1px solid var(--line);
  transition: border-color var(--dur) var(--ease-out);
}
.accordion__item:last-child { border-bottom: 1px solid var(--line); }
.accordion__item.is-open { border-color: var(--line-strong); }

.accordion__trigger {
  width: 100%;
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-6);
  padding: var(--sp-6) 0;
  text-align: left;
  font-family: var(--font-ui); font-weight: 700;
  font-size: var(--fs-h4);
  color: var(--ink);
  transition: color var(--dur) var(--ease-out);
}
.accordion__trigger:hover { color: var(--accent); }

/* +/− como cruz que rota */
.accordion__sym {
  position: relative; flex-shrink: 0;
  width: 20px; height: 20px;
  transition: transform var(--dur-slow) var(--ease-expo);
}
.accordion__sym::before, .accordion__sym::after {
  content: ""; position: absolute; inset: 50% 0 auto 0;
  height: 1.5px; background: var(--accent-2);
  transition: transform var(--dur-slow) var(--ease-expo), opacity var(--dur) var(--ease-out);
}
.accordion__sym::after { transform: rotate(90deg); }
.accordion__item.is-open .accordion__sym { transform: rotate(180deg); }
.accordion__item.is-open .accordion__sym::after { transform: rotate(0deg); opacity: 0; }

.accordion__panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--dur-slow) var(--ease-expo);
}
.accordion__item.is-open .accordion__panel { grid-template-rows: 1fr; }
.accordion__panel > div { overflow: hidden; }
.accordion__panel p {
  padding-bottom: var(--sp-6);
  color: var(--ink-mute);
  line-height: var(--lh-relaxed);
  max-width: 68ch;
  opacity: 0;
  transition: opacity var(--dur) var(--ease-out) 60ms;
}
.accordion__item.is-open .accordion__panel p { opacity: 1; }

/* ==========================================================================
   11 · FORMULARIO
   ========================================================================== */
.form { display: flex; flex-direction: column; gap: var(--sp-6); }

.field { position: relative; display: flex; flex-direction: column; }
.field label {
  font-family: var(--font-ui); font-weight: 600;
  font-size: var(--fs-xs);
  letter-spacing: .04em;
  color: var(--ink-soft);
  margin-bottom: var(--sp-2);
  transition: color var(--dur) var(--ease-out);
}
.field:focus-within label { color: var(--accent); }

.field input, .field select, .field textarea {
  width: 100%;
  background: transparent;
  border: none;
  border-bottom: 1px solid var(--line);
  padding: var(--sp-3) 0;
  font-family: var(--font-body);
  font-size: var(--fs-body);
  color: var(--ink);
  border-radius: 0;
}
.field textarea { resize: vertical; min-height: 84px; }
.field select { cursor: pointer; }
.field select option { background: var(--bg-elev); color: var(--ink); }

/* Subrayado de foco que se dibuja de izquierda a derecha */
.field__underline {
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 2px; background: var(--accent);
  transform: scaleX(0); transform-origin: left center;
  transition: transform var(--dur-slow) var(--ease-expo);
  pointer-events: none;
}
.field:focus-within .field__underline { transform: scaleX(1); }

.field.is-invalid input,
.field.is-invalid select,
.field.is-invalid textarea { border-bottom-color: var(--accent-2); }
.field__error {
  font-size: var(--fs-xs);
  color: var(--accent-2);
  margin-top: var(--sp-2);
  opacity: 0; max-height: 0; overflow: hidden;
  transition: opacity var(--dur) var(--ease-out), max-height var(--dur) var(--ease-out);
}
.field.is-invalid .field__error { opacity: 1; max-height: 3em; }

.form__foot { font-size: var(--fs-xs); color: var(--ink-soft); text-align: center; }
.form__foot--error { color: var(--accent-2); }
.form__foot--error a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }

/* Botón submit: normal → cargando → listo */
.btn[data-state="loading"] { pointer-events: none; }
.btn[data-state="loading"] .btn__label { opacity: .5; }
.btn__spinner { display: none; width: 16px; height: 16px; }
.btn[data-state="loading"] .btn__spinner { display: block; animation: obs-spin .8s linear infinite; }
@keyframes obs-spin { to { transform: rotate(360deg); } }

.success {
  display: none; flex-direction: column; align-items: center; text-align: center;
  gap: var(--sp-5); padding: var(--sp-16) var(--sp-6);
}
.success:not([hidden]) { display: flex; animation: obs-rise var(--dur-slow) var(--ease-expo) both; }
.success .icon { stroke: var(--accent); }
.success__title { font-family: var(--font-ui); font-weight: 700; font-size: var(--fs-h3); color: var(--ink); }
.success__foot  { font-size: var(--fs-sm); color: var(--ink-mute); }
/* Recibe el foco por JS al enviar (ver módulo 9). El anillo no aporta —el
   formulario entero acaba de ser reemplazado por esta tarjeta, que ya es la
   confirmación— y encima dibujaría un marco alrededor de todo el bloque. */
.success[tabindex="-1"]:focus,
.success[tabindex="-1"]:focus-visible { outline: none; }

/* ==========================================================================
   12 · CURSOR PROPIO — pastilla que sigue al puntero.
   Dos capas a propósito: la de afuera la mueve el JS con un translate en
   coordenadas de viewport, y la de adentro se encarga de entrar y salir.
   Mezclar las dos en un solo transform hacía que cada pointermove pisara
   la animación de entrada y la pastilla apareciera de golpe.
   ========================================================================== */
.cursor-pill {
  position: fixed; top: 0; left: 0;
  z-index: var(--z-cursor);
  pointer-events: none;
  /* Única excepción a "will-change se pone y se saca": este elemento existe
     solo para ser transformado, y se mueve en cada pointermove. */
  will-change: transform;
}
.cursor-pill__inner {
  /* Offset para no quedar debajo del puntero. El origen abajo-izquierda
     hace que la escala de entrada salga desde la punta del cursor. */
  display: block;
  margin: 16px 0 0 14px;
  transform-origin: 0 0;
  opacity: 0;
  transform: scale(.85);
  transition: opacity var(--dur-fast) var(--ease-out),
              transform var(--dur) var(--ease-spring);
}
.cursor-pill.is-on .cursor-pill__inner { opacity: 1; transform: scale(1); }

.cursor-pill__label {
  display: inline-block;
  padding: .55rem .9rem;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--ink) 55%, transparent);
  -webkit-backdrop-filter: blur(10px);
          backdrop-filter: blur(10px);
  color: var(--bg);
  font-family: var(--font-ui); font-weight: 600;
  font-size: 11px;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  white-space: nowrap;
}

/* Sin puntero no hay cursor que seguir. El JS ni siquiera lo engancha,
   pero se apaga también acá por las dudas de que quede en el DOM. */
@media (pointer: coarse) {
  .cursor-pill { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .cursor-pill { display: none; }
}

/* ==========================================================================
   COSTURA · .seam — frente iii

   ⚠ REESCRITO. La primera versión era un FUNDIDO de 140-200px con la plancha
   adentro, y estaba mal de raíz. Dos motivos, los dos verificados mirando:

   1 · Una banda de 140px a 2560 de ancho es 18,3:1. Una plancha de 3:2 metida
       ahí con `cover` muestra una tajada del 5% de su alto: sale como rayas
       verticales, no como arquitectura. Ninguna opacidad lo arregla — el
       contenedor está mal. LA PLANCHA NECESITA UNA SECCIÓN, NO UNA TIRA.
       (Vive en .muro-plancha, más abajo.)
   2 · Suavizar un corte no siempre lo mejora. El corte entre la pintura del
       hero y la sección crema era un corte de montaje, limpio y decidido; el
       fundido lo convertía en una mancha que leía como error de render.

   Lo que quedó: el CORTE SE CONSERVA, y la costura es una banda fina del color
   ENTRANTE que lleva la puntuación ornamental — filete y fleurón. El cambio de
   color pasa de golpe arriba de la banda; la banda ya es la sección que entra.
   ========================================================================== */
.seam {
  --seam-to: var(--brand-crema);   /* color de la sección que EMPIEZA */
  --seam-h:  72px;
  --seam-rule: rgba(29, 28, 24, .32);

  position: relative;
  height: var(--seam-h);
  background: var(--seam-to);
  /* SIN overflow: hidden. `overflow: hidden` convierte al elemento en
     contenedor de scroll, y animation-timeline: view() se ata al contenedor
     más cercano — o sea a la costura, que no scrollea nunca. Con overflow
     puesto la regla quedaba clavada en scaleX(1) (medido). Misma familia de
     trampa que el pin sticky de los módulos 11/13/14. */
}

/* El filete tiene que verse contra el lado al que ENTRA, no contra un color
   fijo: en tinta oscura sobre el negro de proyectos desaparecía. */
.seam[data-seam="dark-to-light"] { --seam-rule: rgba(29, 28, 24, .32); }
.seam[data-seam="light-to-dark"] { --seam-rule: rgba(234, 227, 214, .30); }

/* Las tres ubicaciones. Van en CSS y no en style inline: un custom property
   inline le gana a cualquier media query, así que con --seam-h inline el
   achique de móvil no aplicaba nunca (medido: a 390px seguían midiendo 140). */
.seam--a { --seam-to: var(--brand-crema); }                                        /* hero → clientes */
.seam--b { --seam-to: color-mix(in srgb, var(--brand-celeste-lt) 16%, #161511);    /* solucionamos → proyectos */
           --seam-h: 88px; }
.seam--c { --seam-to: var(--brand-crema); }                                        /* testimonios → fases */

/* La CUARTA. No es "la cuarta posición de la home": es la que entrega a NEGRO
   PLANO, y existe porque las otras tres no sirven para entrar a una sección
   oscura sin wash.
   Las variantes de arriba NO son direccionales — cada una guarda el color de la
   sección que empieza en SU posición de la home. --a y --c entregan a crema, y
   --b entrega a color-mix(celeste-lt 16%, #161511) porque en la home lo que
   sigue lleva .proyectos__wash encima. En /proyectos/ y en /nosotros/ las
   secciones oscuras arrancan en --brand-negro pelado, y usar --b dejaba un
   salto medido de ~30 niveles justo en la costura: una costura nueva donde
   íbamos a sacar una.
   Nació en css/proyectos.css (19ª pasada) y se mudó acá en la 20ª, cuando
   /nosotros/ la necesitó también: es una variante de .seam y su familia vive
   en este archivo. La alternativa era duplicarla en dos hojas. */
.seam--negro {
  --seam-to: var(--brand-negro);
  --seam-h: 88px;
}
@media (max-width: 640px) { .seam--negro { --seam-h: 64px; } }
/* La costura B no entrega a --bg-sunk pelado: arriba de .proyectos está
   .proyectos__wash, cuya primera parada es el acento al 16%, así que la sección
   arranca de verdad en rgb(35,43,46). Con #161511 quedaba un salto medido de 30
   niveles — una costura nueva justo donde íbamos a sacar una. Se repite la MISMA
   fórmula del wash en vez de clavar el color, así que si el wash cambia, esto lo
   sigue. El literal --brand-celeste-lt va en vez de var(--accent) porque la
   costura vive fuera del data-theme oscuro. */

/* ── el filete con fleurón ───────────────────────────────────────────────── */
.seam__rule {
  position: absolute;
  left: 0; right: 0; top: 50%;
  height: 1px;
  background: linear-gradient(90deg,
    transparent 0%, var(--seam-rule) 20%, var(--seam-rule) 80%, transparent 100%);
  /* El hueco del centro lo abre una MÁSCARA en px fijos, no paradas en % del
     gradiente: en % el hueco crece con el ancho y a 1440 medía 100px alrededor
     de un fleurón de 40 — se leía como el filete cortado, no como ornamento. */
  -webkit-mask-image: linear-gradient(90deg, #000 calc(50% - 26px),
      rgba(0,0,0,0) calc(50% - 26px), rgba(0,0,0,0) calc(50% + 26px), #000 calc(50% + 26px));
          mask-image: linear-gradient(90deg, #000 calc(50% - 26px),
      rgba(0,0,0,0) calc(50% - 26px), rgba(0,0,0,0) calc(50% + 26px), #000 calc(50% + 26px));
  transform-origin: left center;
  transform: scaleX(1);            /* fallback sin scroll-timeline: ya dibujado */
}
.seam__fleuron {
  position: absolute;
  left: 50%; top: 50%;
  translate: -50% -50%;
  display: grid; place-items: center;
  color: var(--seam-rule);
  opacity: 1;                      /* fallback */
}
.seam__fleuron svg { display: block; width: 40px; height: 14px; }

/* 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. main.js:12 ya detectaba el
   soporte en supportsScrollTimeline y no lo usaba nunca. Es la ocasión. */
@supports (animation-timeline: view()) {
  .seam__rule {
    transform: scaleX(0);
    animation: obs-seam-draw linear both;
    animation-timeline: view();
    animation-range: entry 15% cover 42%;
  }
  .seam__fleuron {
    opacity: 0;
    animation: obs-seam-fleuron linear both;
    animation-timeline: view();
    animation-range: cover 36% cover 56%;
  }
}
@keyframes obs-seam-draw    { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes obs-seam-fleuron { from { opacity: 0; scale: .8; } to { opacity: 1; scale: 1; } }

@media (prefers-reduced-motion: reduce) {
  .seam__rule    { transform: scaleX(1); animation: none; }
  .seam__fleuron { opacity: 1; scale: 1; animation: none; }
}

@media (max-width: 640px) {
  .seam    { --seam-h: 56px; }
  .seam--b { --seam-h: 64px; }
  .seam__fleuron svg { width: 30px; height: 10px; }
}

/* ── El muro: la plancha detrás de una sección entera ──────────────────────
   La aparición más fuerte, y la única que no vive en una costura. Misma
   plancha y mismo criterio de bordes, pero a escala de sección y con
   disolución radial para que no toque ningún borde. */
/* ⚠ DEUDA MEDIDA (18ª pasada) — assets/plancha.webp mide 1300 × 792 y acá se
   usa con mask-size: cover sobre una sección de ancho completo. A 1440 CSS px
   ya es upscale; a DPR 2 son ~2880 físicos contra 1300 de fuente, o sea más del
   doble. Y es un grabado de buril: líneas de UN píxel, el peor material posible
   para escalar. Por eso el muro se ve blando en Testimonios.

   El original del Met (claude/planchas/21-363254.png) es 2200 × 1467, así que
   hay 1,64× de detalle real sin tocar. Se intentó regenerar el asset y NO SE
   PUDO reproducir el existente: recuperada la geometría (fuente escalada a 1339
   de ancho, recorte en x=21 y=24, correlación .95 a baja resolución), el
   derivado igual no coincide —error medio 27/255— porque lleva además un
   tratamiento tonal que no está anotado en ningún lado. Regenerarlo a ojo
   cambiaría una plancha que se eligió sobre render.

   Para cerrarlo hace falta la receta original (curva/niveles y si hubo blur).
   Con eso, el archivo sale a ~2136 × 1301 y esto se ve nítido. Nota: el mask va
   en el canal ALFA — el RGB del webp es plano (228,228,228) y no se usa. */
/* ⚠ LA URL DE LA PLANCHA NO VIVE ACÁ, Y ES A PROPÓSITO (45ª pasada).
   assets/plancha.webp pesa 258 KB y era, después del hero, el archivo más
   grande del arranque de la home: medido en la traza, TERMINABA A LOS 443 ms,
   o sea adentro de la ventana de LCP. Y no se ve nunca en esa ventana: el muro
   vive en Testimonios y en el pie, muy abajo del pliegue.

   Antes de diferirlo se intentó lo obvio —achicar el archivo— y NO SE PUDO, lo
   cual conviene dejar escrito para que nadie lo reintente: la imagen son
   1300×792 con sólo 16 niveles de alfa y 8 de RGB, así que parece
   comprimibilísima, y no lo es. Probados contra el original de 258 KB:
     WEBP lossless modo L ... 254 KB (99%)   alfa idéntico
     WEBP RGBA q40 alfa 100 . 253 KB (98%)
     PNG modo L ............. 421 KB (164%)
     PNG RGBA ............... 582 KB (226%)
   Es grabado de buril: líneas de un píxel, ruido de alta frecuencia, el peor
   material posible para cualquier codificador. 258 KB ES su tamaño.

   Entonces lo que se movió es el CUÁNDO. La máscara se aplica recién con el
   evento `load`, cuando el LCP ya ocurrió. Sin JS se aplica de una (la regla
   de html.no-js más abajo), así que no hay ningún camino en el que el muro
   quede sin máscara.

   Y arranca en opacity 0, no en .18: sin máscara, `background: var(--ink)`
   sería un rectángulo oscuro tapando la sección entera. Invisible-y-después-
   texturado es un estado que el diseño YA acepta — es exactamente lo que hace
   el @supports de abajo cuando el navegador no sabe enmascarar. */
.muro-plancha {
  position: absolute; inset: 0; z-index: 0;
  pointer-events: none;
  background: var(--ink);
  opacity: 0;
  transition: opacity 500ms var(--ease-out);
  -webkit-mask-size: cover, 100% 100%;
          mask-size: cover, 100% 100%;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
}
html.no-js .muro-plancha,
html.obs-plancha .muro-plancha {
  /* .18 elegido sobre render comparando 7 / 12 / 18 / 26%. A 7% no se veía nada
     (mismo hallazgo que la hoja de contactos: la crema sobre negro necesita más
     opacidad de la que decía la dirección); a 26% ya compite con el texto. */
  opacity: .18;
  -webkit-mask-image: url("../assets/plancha.webp"),
    radial-gradient(118% 86% at 50% 46%, #000 30%, rgba(0,0,0,0) 100%);
          mask-image: url("../assets/plancha.webp"),
    radial-gradient(118% 86% at 50% 46%, #000 30%, rgba(0,0,0,0) 100%);
}
@supports not ((-webkit-mask-image: url("#")) or (mask-image: url("#"))) {
  .muro-plancha { display: none; }
}
/* En el footer la plancha se ancla ABAJO y arranca por debajo del resplandor
   superior, para no pelearse con el degradé de la octava pasada — que resuelve
   la entrada al pie y cuya intensidad ya se eligió sobre render. */
.muro-plancha--footer {
  -webkit-mask-position: center bottom;
          mask-position: center bottom;
}
/* ⚠ La opacidad del pie va acá adentro y NO en la regla de arriba: si quedara
   suelta, `html.obs-plancha .muro-plancha` (0,2,1) le ganaría a
   `.muro-plancha--footer` (0,1,0) y el pie tomaría el .18 del muro grande.
   Mismo gateo que el muro, por el mismo motivo: es la misma imagen de 258 KB. */
html.no-js .muro-plancha--footer,
html.obs-plancha .muro-plancha--footer {
  opacity: .13;
  -webkit-mask-image: url("../assets/plancha.webp"),
    linear-gradient(180deg, rgba(0,0,0,0) 0%, #000 34%, #000 88%, rgba(0,0,0,0) 100%);
          mask-image: url("../assets/plancha.webp"),
    linear-gradient(180deg, rgba(0,0,0,0) 0%, #000 34%, #000 88%, rgba(0,0,0,0) 100%);
}


/* ==========================================================================
   14 · ANCHO — el kit de migración a sangre

   Nace en la 22ª pasada para /nosotros/, y está escrito para MIGRAR EL RESTO
   DEL SITIO después: por eso vive acá y no en la hoja de esa página.

   HOY NO LO USA NINGUNA OTRA PÁGINA. Es puramente aditivo: no toca un solo
   selector existente, así que no puede romper nada de lo que ya anda. Cuando
   una página se migre, el cambio es cambiar `.container` por `.sangre` en su
   markup —y envolver en `.marco` lo que deba quedar acotado— y nada más.

   POR QUÉ DOS CLASES Y NO UNA. Medí brumalab.agency/nosotros a cinco anchos y
   su maqueta NO es uniforme:

     · a sangre con borde  — cabecera y los dos paneles oscuros. El borde baja
       48 → 36 → 24 px según el corte; los paneles usan un filo fijo de 12.
     · acotado a 1200 px   — los tres bloques de TEXTO (estudio, fundamentos,
       método). No se estiran ni a 1920, y no es un descuido: un renglón de
       1800 px no se lee, se pierde el salto de línea.

   O sea que "ancho completo" es la envoltura, no la medida del texto. `.sangre`
   da lo primero y `.marco` devuelve lo segundo cuando hace falta.

   .container NO se toca: lo usan las otras siete páginas.
   ========================================================================== */
:root                     { --borde: 3rem; }        /* Bruma: 48px de 992 para arriba */
@media (max-width: 991px) { :root { --borde: 2.25rem; } }   /* Bruma: 36px */
@media (max-width: 767px) { :root { --borde: 1.5rem;  } }   /* Bruma: 24px */

.sangre       { width: 100%; padding-inline: var(--borde); }
/* El filo de los paneles embutidos: 12px fijos en todos los cortes (medido). */
.sangre--filo { padding-inline: .75rem; }
/* Va DENTRO de .sangre: devuelve la medida de lectura sin perder el borde. */
.marco        { width: 100%; max-width: var(--container); margin-inline: auto; }

/* ⚠ CORRECCIÓN DE LA 23ª: NO ERAN TRES BLOQUES ACOTADOS, ERAN DOS.
   La 22ª metió el Método dentro de `.marco` junto con el Estudio y los
   Fundamentos. Volví a medir Bruma a 1440 / 1920 / 2055 y su bloque de método
   NO usa el `container-small` de los otros dos: va a sangre con un tope propio
   de 1920 px. Medido:

     ancho    estudio · fundamentos      método
     1440     120 + 1200                 48 + 1344
     1920     360 + 1200                 48 + 1824
     2055     428 + 1200                 68 + 1920   ← el tope muerde acá

   O sea que arriba de 1920 la caja se centra dentro del borde de 48, que es
   exactamente lo que da 68 px de sangría a 2055. Con `.marco` sus tres
   tarjetas medían 378 px a 1440 donde Bruma da 427: un 12% más chicas, que es
   la diferencia que se veía a simple vista.

   Esta clase es el tope ancho. `.marco--ancho` sin `.marco`: son alternativas,
   no se acumulan. */
.marco--ancho { width: 100%; max-width: 120rem; margin-inline: auto; }

/* ==========================================================================
   15 · BOTÓN FLOTANTE DE WHATSAPP — .wsp

   Lo que NO es: el widget verde redondo con el logo pegado abajo a la derecha.
   Ese verde no existe en ningún lado de esta paleta (el único verde de la casa
   es --ok y vive SÓLO adentro de los mockups, ver tokens.css § 1), así que
   traerlo sería meter el primer color ajeno del sitio en la capa que está
   SIEMPRE encima de todo. Acá el botón es de la marca y se reconoce por la
   forma del glifo, no por el color.

   TRES COSAS QUE NO SE INVENTAN, SE TRAEN:
   · el magnético de .btn (§ 2) — mismo [data-magnetic], mismo módulo 7, mismos
     6px de tope. Un botón que se comporta distinto a los demás se siente como
     de otra página.
   · el 0fr → 1fr del acordeón (§ 10) — es como se abre la etiqueta sin clavar
     un ancho a mano. Si mañana el texto cambia, el botón se adapta solo.
   · el canto + sombra de la isla del nav (sections.css § NAV).

   EL TEMA LO PONE EL JS (módulo 18) Y ES EL DE LA SECCIÓN QUE TIENE DEBAJO,
   igual que el nav. Con eso el contraste se resuelve solo y en los dos temas:
   la pastilla es SIEMPRE --ink y el glifo SIEMPRE --accent-ink, o sea tinta
   sobre el fondo de la sección y glifo del color del fondo. Sobre crema sale
   negro con glifo crema; sobre negro sale crema con glifo negro. Nunca hay que
   elegir un color "que ande en los dos", que es donde estas cosas se apagan.
   ========================================================================== */
.wsp {
  /* Los tres los escribe alguien más y se componen en UN transform (abajo):
     --mag-* es del módulo 7, --wsp-alto del hover, --wsp-escala del guardado.
     Separados porque si compartieran propiedad se pisarían entre ellos. */
  --mag-x: 0px; --mag-y: 0px;
  --wsp-alto: 0px;
  --wsp-escala: 1;
  --wsp-margen: clamp(1rem, 0.5rem + 1.4vw, 1.75rem);

  position: fixed;
  right: var(--wsp-margen);
  /* env() y no un valor pelado: en iPhone la barra de gestos se come el borde
     de abajo y el botón queda mordido. Con el fallback en 0px, un navegador
     que no conoce env() usa el margen solo. */
  bottom: calc(var(--wsp-margen) + env(safe-area-inset-bottom, 0px));
  z-index: var(--z-wsp);

  /* Grid de dos columnas: el glifo mide lo que mide y la etiqueta arranca
     en 0fr, o sea colapsada. El hover la lleva a 1fr y el ancho se resuelve
     con el texto que haya adentro. Ver § 10: misma técnica, otro eje. */
  display: grid;
  grid-template-columns: auto 0fr;
  align-items: center;

  min-height: 56px;                 /* objetivo táctil ≥44px (HIG / WCAG 2.2) */
  padding-inline: 16px;             /* 16 + 24 del glifo + 16 = 56 = círculo */
  border-radius: var(--radius-pill);

  /* ⚠ ANULA EL FONDO DE [data-theme] (base.css § 214), que pintaría --bg.
     Acá el fondo es la TINTA justamente para ser lo contrario de la sección
     que tiene debajo. Gana por orden de hoja: components.css va después. */
  background: var(--ink);
  color: var(--accent-ink);

  /* Canto de vidrio, misma idea que la isla del nav pero con el triplete al
     revés. Y es al revés A PROPÓSITO: --glass-rgb es la tinta y --spec-rgb es
     la luz, y los dos fallan acá porque la pastilla YA es del color de la
     tinta — un canto de tinta sobre tinta no se ve. El único color que está
     garantizado en contraste contra este fondo es el del propio glifo, así
     que el pelo sale de ahí. Sobre la pastilla negra da un filo de luz; sobre
     la crema, uno de sombra. Nunca se apaga. */
  box-shadow:
    inset 0 0 0 var(--hairline) color-mix(in srgb, var(--accent-ink) 22%, transparent),
    var(--shadow-lift);

  transform: translate3d(var(--mag-x), calc(var(--mag-y) + var(--wsp-alto)), 0)
             scale(var(--wsp-escala));
  transition:
    grid-template-columns var(--dur) var(--ease-out),
    transform  var(--dur) var(--ease-spring),
    background var(--dur) var(--ease-out),
    box-shadow var(--dur) var(--ease-out),
    opacity    var(--dur) var(--ease-out),
    visibility 0s;
}

/* El glifo no se deforma cuando la caja se estira: es el ítem de una columna
   `auto` y tiene medida propia. */
.wsp__glifo {
  display: block;
  width: 24px; height: 24px;
  fill: currentColor;
}

/* El overflow vive ACÁ y no en el <a>, y es la misma advertencia que tiene la
   isla del nav: el anillo de :focus-visible sale con outline-offset 3px, o sea
   FUERA de la caja, y un overflow: hidden en el enlace lo recortaría contra el
   canto de la cápsula. Recortando sólo la columna del texto, el foco queda
   entero y la etiqueta igual se esconde. */
.wsp__texto { overflow: hidden; min-width: 0; }

.wsp__label {
  display: block;
  padding-left: var(--sp-3);        /* el aire aparece CON el texto */
  font-family: var(--font-ui);
  font-weight: 600;
  font-size: var(--fs-sm);
  line-height: 1;
  white-space: nowrap;              /* la etiqueta no se parte nunca */
  opacity: 0;
  transform: translateX(-6px);
  transition:
    opacity   var(--dur-fast) var(--ease-out),
    transform var(--dur) var(--ease-spring);
}

/* :focus-visible junto al hover, no como un estado aparte: quien llega por
   teclado tiene que ver lo mismo que quien llega con el puntero. */
.wsp:hover,
.wsp:focus-visible {
  grid-template-columns: auto 1fr;
  background: var(--accent);
  --wsp-alto: -2px;
  box-shadow:
    inset 0 0 0 var(--hairline) color-mix(in srgb, var(--accent-ink) 26%, transparent),
    var(--shadow-lift);
}
.wsp:hover .wsp__label,
.wsp:focus-visible .wsp__label { opacity: 1; transform: none; }

/* Se aprieta como los demás botones de la casa (§ 2, .btn:active). */
.wsp:active { --wsp-escala: .96; }

/* --- Guardado: mientras el hero está en pantalla y cuando llega el pie -----
   El DEFAULT es VISIBLE y la clase es la que lo esconde, nunca al revés. Sin
   JS —o si main.js se cae— el botón se queda puesto y funciona, porque el
   href real ya viene escrito en el HTML. Al revés (default oculto, JS que
   muestra) la página sin JS se queda sin canal de contacto flotante.
   La demora de visibility es para que el fundido llegue a verse: sin ella
   salta a hidden en el cuadro 1 y el botón desaparece de golpe. */
.wsp.is-guardado {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  --wsp-escala: .72;
  --wsp-alto: 8px;
  transition:
    transform  var(--dur) var(--ease-out),
    opacity    var(--dur) var(--ease-out),
    visibility 0s linear var(--dur);
}

/* --- Táctil: no hay hover que abrir, así que el botón es sólo el círculo ---
   Sin esto, un :hover pegado (que en táctil pasa: el tap deja el estado puesto
   hasta el próximo toque) dejaría la etiqueta abierta sin que nadie la pidió. */
@media (hover: none) {
  .wsp:hover { grid-template-columns: auto 0fr; background: var(--ink); --wsp-alto: 0px; }
  .wsp:hover .wsp__label { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  /* Se apagan los DESPLAZAMIENTOS, no el botón: el cambio de fondo y el fundido
     se quedan, que es la alternativa que pide el manual. El magnético ya no
     corre (módulo 7 se apaga solo), así que el transform queda quieto. */
  .wsp,
  .wsp__label { transition-property: background, box-shadow, opacity, visibility; }
  .wsp:hover, .wsp:focus-visible { --wsp-alto: 0px; }
  .wsp:active { --wsp-escala: 1; }
  .wsp.is-guardado { --wsp-escala: 1; --wsp-alto: 0px; }
  .wsp__label { transform: none; }
}
