/* ═══════════════════════════════════════════════════════════════════════════
   🔄 EL VOCABULARIO DE ESTADO CAMBIÓ EL 2026-11-02 (166ª) — y es traducción, no rediseño.

   Este visor se montaba sobre `CMS.Modal` + `morph.js`, que marcaban el estado con
   CLASES (`.is-open`, `.is-morphing`). Ahora se monta sobre el `Dialog` de `nd/`,
   que lo marca con un ATRIBUTO —`data-dialog-state`, una lista de palabras:
   `open · closing · in · prep · settled`— más `data-dialog-effect`.

   🔑 **Y POR QUÉ IMPORTA, medido:** al migrar el JS sin traducir esto, el diálogo
   abría de verdad (`isOpen=true`, `data-dialog-state="open in settled"`) y la
   pantalla se quedaba en `opacity: 0` con `pointer-events: none`. Un visor que
   abre y no se ve, **sin un solo error en consola**: el mecanismo y el vestido
   hablaban idiomas distintos. Lo destapó la sonda, no la lectura — las dos
   mitades se leen perfectas por separado.

   ⚖️ La traducción es 1:1 a propósito. La visibilidad, el velo y el layout a
   pantalla completa los gobierna ya `dialog.css` (`.dialog[data-dialog-state~=…]`,
   `data-dialog-layout="full"`); lo que queda aquí es el VESTIDO de este visor y la
   entrada de sus piezas (barra, tira de miniaturas, flechas).
   ═══════════════════════════════════════════════════════════════════════════ */

/* ══════════════════════════════════════════════════════════════════════════
   EL VISOR DE FOTOS — el COMPONENTE, autocontenido
   ══════════════════════════════════════════════════════════════════════════

   Su composición vive en `templates/partials/media-viewer.html` y su vestido
   aquí. Los dos juntos son el componente, y el partial ENCOLA esta hoja: quien
   lo incluya la recibe sin tener que acordarse de nada.

   ── POR QUÉ EXISTE ESTA HOJA (2026-10-09) ──────────────────────────────────

   🔴 Adán trajo una captura del ÁLBUM del portal de familias: los controles del
   visor —pantalla completa, «Descargar», «Cerrar»— aparecían **sueltos en el pie
   de la página**, fuera de todo diálogo. 📐 Medido: el visor CERRADO ocupaba
   756×25 px con 3 controles visibles.

   La causa no era la pieza: el partial subió al core el 2026-10-01 y **su CSS se
   quedó en `cms.css`**, que es la hoja del PANEL. El portal es público y no la
   carga, así que el marcado se desplegaba en el flujo del documento. Las dos
   mitades estaban bien por separado — el defecto vivía en que la pieza viajaba
   sin su presentación.

   🔑 Y el patrón NO se inventa aquí: lo fijó Adán en la 49ª con estas palabras,
   *«dejar lo del slider como componente, autocontenido y reutilizable; se puede
   usar en front o en backend»*, y `assets/css/carousel.css` es su precedente.
   La ficha de aquel día lo dejó dicho: *«el patrón es una hoja por componente, y
   el siguiente que lo necesite lo replica»*. Éste es el siguiente.

   ── TRES DECISIONES QUE SE TOMARON AQUÍ, Y POR QUÉ ─────────────────────────

   **(1) La trae `enqueue_style`, NO un `<link>` en los layouts.** `carousel.css`
   se enlaza en `layouts/admin.html` y `layouts/base.html`, y por eso **no llega
   a kita**: ese portal tiene tema propio y su layout enlaza sólo `site.css`
   (medido). Un `<link>` del layout del core se pierde en silencio cuando alguien
   escribe su tema; lo que la PIEZA declara, no. Es `theme:check` aplicado al CSS.

   **(2) El diálogo del visor es SUYO, no `.overlay` del panel.** Las reglas
   `.overlay`/`.modal` viven en `cms.css` y mudarlas tocaría las ~20 pantallas de
   diálogo del panel en cinco sitios vivos para arreglar un portal. Un lightbox
   autocontenido es además lo que hacen PhotoSwipe, Fancybox y GLightbox — las
   mismas referencias que cita el partial: ninguno te exige tener un `.overlay`.
   El marcado conserva la clase `.overlay` porque `CMS.Modal` opera sobre ella;
   lo que no hereda es su VESTIDO.

   **(3) Todos los tokens llevan fallback, y no es higiene: es el requisito.**
   📐 Medido en el portal: `--color-bg`, `--color-surface`, `--space-5` y el resto
   de la paleta del panel **no existen allí** (sólo `--app-accent`). Sin fallback
   esta hoja daría un visor sin colores ni espaciado. Los valores son los del tema
   claro **medidos en el navegador**, no copiados de `design-system.css` — esa
   hoja declara los dos temas y el primero que se lee es el OSCURO.

   ── 🔴 Y UN DEFECTO QUE APARECIÓ AL MUDARLA, medido ────────────────────────

   `cms.css` tenía **dos bloques `#mediaViewer .modal`**: uno con tokens (commit
   `ccda1a8a2`, 12:14, *«fuera la curita de los colores clavados»*) y, más abajo,
   el de la curita con `#0b0b0d` a pelo (`1f45814c5`, 10:57). **El commit que
   anunciaba retirarla la dejó debajo**, así que ganaba por orden de cascada: el
   visor del panel llevaba una semana sirviendo `rgb(11,11,13)` con el token
   `--color-bg` en `#f0f2f8`. Verificado en navegador antes de tocar nada.
   Al mudar el componente queda **una sola versión**, la de tokens, que es la que
   Adán pidió: *«ya tenemos los temas light/dark, me refería a esa discontinuidad
   con lo que nosotros mismos hemos definido»*.

   ⬜ Declarado y NO hecho: `.mviewer__stage` conserva un `#0b0b0d` con su razón
   escrita. Es inocuo —`#mediaViewer .mviewer__stage` lo pisa a `transparent`—,
   pero es un color clavado y le toca token el día que haya un segundo visor.
   ══════════════════════════════════════════════════════════════════════════ */

/* ── EL DIÁLOGO DEL VISOR — suyo, para poder vivir fuera del panel ──────────
 *
 * Equivale a lo que `.overlay`/`.modal` dan en `cms.css`, pero acotado por `#id`
 * para que ni dependa de esa hoja ni la pise donde sí está. En el panel las dos
 * conviven y gana ésta por especificidad, que es lo que ya pasaba antes con
 * `#mediaViewer.dialog`: el comportamiento no cambia.
 *
 * 🔑 `pointer-events` cerrado va también a los HIJOS, y con `!important`: hay uno
 * que se lo salta por su cuenta —`.cz-slide.is-on`, del carrusel— y sin esto un
 * visor cerrado, invisible y a pantalla completa sigue capturando el ratón. Lo
 * diagnosticó Adán el 2026-10-01 viendo que la lista de debajo perdía el hover. */
#mediaViewer.dialog {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: rgba(0, 0, 0, .6);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  opacity: 0;
  pointer-events: none;
  transition: opacity 220ms ease;
}
#mediaViewer.dialog[data-dialog-state~="open"] {
  opacity: 1;
  pointer-events: auto;
}
/* 🗑️ Aquí vivía un `pointer-events: none !important` para el visor cerrado y sus
   hijos. Se retira el 2026-11-02: `dialog.css` ya lo declara para `.dialog`
   cerrado (su línea 29), así que esto era la segunda copia — y la primera copia,
   la de `cms.css` escrita para `.overlay` de `CMS.Modal`, es la que dejó este
   visor VISIBLE PERO SORDO al migrar: `:not(.is-open)` es cierto para siempre
   cuando el estado ya no se marca con esa clase. Por eso este visor suelta
   `.overlay` y pasa a `.dialog`: el vestido tiene que hablar el idioma de su
   mecanismo. */

/* ── Y LA CAJA, por la misma razón ──────────────────────────────────────────
 *
 * 🔴 Medido abriendo el visor en el portal el 2026-10-09, DESPUÉS de que la sonda
 * diera verde: la foto no aparecía, la tira de miniaturas salía arriba y los
 * controles en medio de la nada. La sonda sólo medía el visor CERRADO, y cerrado
 * estaba perfecto — «medir un momento no es medir la ventana».
 *
 * Lo que faltaba es lo que `.modal` aporta en `cms.css`: la columna flex. Sin
 * ella el escenario no se lleva el alto y el orden visual se deshace. Va aquí
 * porque un portal público no carga esa hoja. */
/* ── Y SUS BOTONES ──────────────────────────────────────────────────────────
 *
 * 🔴 Mismo hallazgo, misma causa: «Download» salía SUBRAYADO como un enlace
 * porque `.btn` vive en `design-system.css`, que es del panel. Esto es la FORMA
 * del control; su color lo pone más abajo el tema del visor.
 *
 * Los tokens llevan su fallback medido, como el resto de la hoja. */
/* 🚨 `hidden` GANA, y hay que decirlo porque el bloque de abajo declara
   `display`. Un atributo `hidden` vale `display:none` de la hoja del navegador,
   así que CUALQUIER `display` propio lo pisa: medido en Chrome el 2026-09-09, un
   `<button class="btn" hidden>` computaba 53×36 px. Mismo contrato que
   `design-system.css`, y lo vigila `HiddenFromScriptSurvivesTest`. */
#mediaViewer .btn[hidden] { display: none; }

#mediaViewer .btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2, 8px);
  padding: var(--btn-py, 7px) var(--btn-px, 12px);
  min-height: var(--btn-height, 34px);
  border-radius: var(--radius-sm, 8px);
  font-family: inherit;
  font-size: var(--text-sm, 13px);
  font-weight: 500;
  line-height: 1;
  cursor: pointer;
  border: 1px solid transparent;
  white-space: nowrap;
  text-decoration: none;
  user-select: none;
  background: transparent;
}
#mediaViewer .btn:active { transform: scale(0.97); }
#mediaViewer .btn svg {
  width: 14px; height: 14px; flex-shrink: 0;
  stroke: currentColor; fill: none;
  stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}

/* ── 🎫 VISOR DE COMPROBANTES ──────────────────────────────────────────────
   El justificante se mira DENTRO del panel, no en otra pestaña. Lo pidió Adán
   el 2026-09-07: «cuando la ves abre directamente, ¿no sería mejor usar el
   dialog?… la idea es dar esa sensación de aplicación nativa (premium)».

   Se monta sobre `.overlay` + `.modal`, que ya traen el velo con `backdrop-filter`
   y la entrada con `translateY + scale`: aquí sólo va lo que un visor de
   imágenes necesita y una caja de diálogo no tiene.

   🔑 **Y por qué el efecto de paso vive AQUÍ y no se reusa el del sitio.** El
   motor (`czCarousel`) SÍ se reusa —es JavaScript y el panel también carga
   Alpine—, pero sus clases `.cz-*` viven en `site.css`, y **el panel no carga
   esa hoja**: medido el 2026-09-07, el sitio público carga `site.css` y el panel
   `design-system.css` + `cms.css`, sin ninguna hoja en común. Moverlas al design
   system las quitaría del sitio público, que sí lo necesita. El hueco real —que
   el core no tiene una capa CSS compartida entre sus dos caras— queda declarado
   en «Decisiones diferidas»; duplicarlo en silencio sería la segunda copia que
   `principios.md` §8 prohíbe, y decirlo es la mitad del trabajo. */

/* ── PANTALLA COMPLETA DE VERDAD ────────────────────────────────────────────
 *
 * 🔴 Medido con Adán el 2026-10-01: *«el fullscreen me lo imaginaba más bonito;
 * aquí lo que haces es que centras el diálogo»*. Y tenía razón — pedir
 * `requestFullscreen()` sobre el `.overlay` llevaba el ELEMENTO a pantalla
 * completa, pero el `.modal` de dentro conservaba su ancho máximo y sus
 * esquinas: una ventanita centrada sobre fondo negro.
 *
 * 🎨 Su referencia (el configurador de VW) enseña el canon y es el de cualquier
 * visor serio —Google Fotos, Apple Fotos, Lightbox de Unsplash—: **la foto de
 * borde a borde y los controles FLOTANDO encima**, no una franja que le robe
 * alto. Aquí eso significa tres cosas, y las tres sólo dentro de `:fullscreen`,
 * para que el diálogo normal no cambie:
 *
 *   1. el `.modal` pierde ancho máximo, radio y sombra: pasa a ser el lienzo;
 *   2. la barra inferior se despega del flujo y se posa SOBRE la imagen, con un
 *      degradado para que el texto se lea esté la foto clara u oscura;
 *   3. la tira de miniaturas hace lo mismo, justo encima de la barra.
 *
 * `:-webkit-full-screen` va aparte y no en la misma lista: un selector que el
 * navegador no entiende invalida la REGLA ENTERA, así que agruparlos dejaría sin
 * estilo a los que sí la soportan. */
#mediaViewer:fullscreen .modal,
#mediaViewer:-webkit-full-screen .modal {
  max-width: none;
  width: 100%;
  height: 100%;
  /* 🔴 `max-height: 90vh` y el `border` de 1 px son del diálogo NORMAL, y en
   * pantalla completa se notaban los dos: el alto dejaba una franja y el borde
   * dibujaba un marco claro en todo el perímetro — *«ese borde blanco alrededor
   * le quita glamour, es como demasiado»*. En un visor el marco sobra: el
   * encuadre lo pone la propia foto. */
  max-height: none;
  margin: 0;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  background: #000;
}

/* La foto usa TODO el alto disponible. Su tope normal —`min(64vh, 560px)`—
 * existe para que un comprobante no desborde un diálogo; aquí el diálogo ES la
 * pantalla, así que ese tope deja la foto flotando en medio de un marco negro,
 * que es justo lo contrario de «pantalla completa». */
:fullscreen .mviewer__img,
:-webkit-full-screen .mviewer__img {
  max-height: 100%;
  max-width: 100%;
  height: auto;
  width: auto;
}

:fullscreen .mviewer__stage,
:-webkit-full-screen .mviewer__stage {
  height: 100%;
  max-height: none;
}

/* ── EL VISOR, CON EL TEMA DEL PANEL ────────────────────────────────────────
 *
 * 🔴 **Esto sustituye a un apaño, y conviene que quede dicho.** El 2026-10-02 el
 * visor se dejó oscuro con `#0b0b0d` y `rgba(255,255,255,…)` **a pelo**, para
 * tapar que los botones no se leían. Adán lo llamó por su nombre: *«resuelve el
 * problema que te describí, sí, pero con una curita… ya tenemos los temas
 * light/dark, me refería a esa discontinuidad con lo que nosotros mismos hemos
 * definido»*. Tenía razón: el `CLAUDE.md` lo declara invariante —**tokens, nunca
 * colores hardcodeados**— y un color clavado deja la pieza fuera del tema para
 * siempre.
 *
 * 🎨 Y su referencia (`demo.io200.com`) enseña por qué además era un error de
 * DISEÑO, no sólo de higiene: un visor de galería **no es una sala de cine**. Va
 * sobre la superficie del panel, con los controles discretos y la foto
 * respirando — que es lo que hacen Google Fotos en su tema claro, Apple Fotos y
 * SmugMug. El negro sólo es canon cuando el producto ES el cine.
 *
 * Con tokens, claro y oscuro salen de serie y sin una línea más. */
#mediaViewer .modal {
  /* La CAJA — lo que `.modal` aporta en `cms.css` y un portal público no recibe.
     🔴 Medido abriendo el visor el 2026-10-09, DESPUÉS de que la sonda diera
     verde: sin la columna flex el escenario no se lleva el alto, la foto no
     aparecía y la tira de miniaturas salía arriba. La sonda sólo medía el visor
     CERRADO, y cerrado estaba perfecto — medir un momento no es medir la
     ventana. */
  position: relative;
  display: flex;
  flex-direction: column;

  max-width: none;          /* la copia base pedía `min(940px, 96vw)` y PERDÍA por
                               especificidad; al fundirse se conserva quien ganaba. */
  width: 100vw;
  height: 100vh;
  max-height: none;
  margin: 0;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  background: var(--color-bg, #f0f2f8);
  padding: 0;
  overflow: hidden;         /* de la copia base: no conflictuaba, así que aplicaba. */

  /* La ENTRADA — ver el comentario largo más abajo: todo en `transform`/`opacity`,
     que el compositor anima sin recalcular el diseño. */
  transform: scale(.94);
  opacity: 0;
  transition: transform 260ms cubic-bezier(.22, 1, .36, 1), opacity 200ms ease;
}

#mediaViewer .mviewer__stage {
  flex: 1;
  min-height: 0;
  height: auto;
  background: transparent;
  padding: var(--space-5, 20px);
}

#mediaViewer .mviewer__img {
  max-height: 100%;
  max-width: 100%;
  width: auto;
  height: auto;
  box-shadow: 0 10px 40px rgba(0, 0, 0, .18);
}

#mediaViewer .mviewer__bar,
#mediaViewer .cz-thumbs {
  background: transparent;
  border: 0;
  color: var(--color-text, #1a1f35);
  /* Los controles ENTRAN después de la foto (retardo de 180 ms): da la sensación
     de que la imagen «llega» y la interfaz la acompaña, en vez de todo de golpe.
     Va aquí y no en una segunda regla con el mismo selector — eso es la copia que
     `CssRulesAreWrittenOnceTest` persigue. */
  opacity: 0;
  transition: opacity 200ms ease 180ms;
}

/* Los controles, DISCRETOS: en la referencia son iconos finos que no compiten
 * con la foto. Ganan presencia al pasar el ratón, no antes. */
#mediaViewer .mviewer__bar .btn {
  background: transparent;
  border-color: transparent;
  color: var(--color-muted, #6d7590);
}
#mediaViewer .mviewer__bar .btn:hover {
  background: var(--color-surface-2, #f5f6fa);
  color: var(--color-text, #1a1f35);
}
#mediaViewer .mviewer__bar .btn:focus-visible {
  outline: 2px solid var(--color-accent, #4f7cff);
  outline-offset: 2px;
}
#mediaViewer .mviewer__title { color: var(--color-text, #1a1f35); }
#mediaViewer .mviewer__sub,
#mediaViewer .mviewer__count { color: var(--color-muted, #6d7590); }

#mediaViewer .mviewer__nav {
  background: transparent;
  color: var(--color-muted, #6d7590);
  /* Los controles ENTRAN después de la foto (retardo de 180 ms): da la sensación
     de que la imagen «llega» y la interfaz la acompaña, en vez de todo de golpe.
     Va aquí y no en una segunda regla con el mismo selector — eso es la copia que
     `CssRulesAreWrittenOnceTest` persigue. */
  opacity: 0;
  transition: opacity 200ms ease 180ms;
}
#mediaViewer .mviewer__nav:hover {
  background: var(--color-surface-2, #f5f6fa);
  color: var(--color-text, #1a1f35);
}

/* ── LAS TRANSICIONES, que es lo que lo hace sentir una aplicación ──────────
 *
 * 🎨 Descritas por Adán mirando la referencia: *«cuando abres una imagen hace una
 * transición suave, el efecto se asemeja a un zoom-in, y cuando se completa,
 * fade-in de los botones; al cambiar de imagen aplica el típico spinner de
 * precarga (si es la primera vez; luego ya no, porque está cargada); al cerrar,
 * zoom-out»*.
 *
 * 🔑 **Por qué en ESE orden y no todo a la vez:** la foto entra primero y los
 * controles después porque lo que la persona vino a ver es la foto — mover los
 * dos juntos obliga a elegir dónde mirar. Es lo mismo que hace iOS al abrir una
 * imagen, y lo que su propio componente de precarga ya hacía en `onlainez.net`.
 *
 * Todo en `transform`/`opacity`: son las dos propiedades que el compositor anima
 * sin recalcular el diseño, así que esto no cuesta un reflujo por foto.
 * `prefers-reduced-motion` lo apaga entero — para algunas personas el zoom es
 * mareo. */
/* ⬆️ Su entrada (`transform`/`opacity`/`transition`) está arriba, EN LA MISMA
 * regla: separarla en un segundo `#mediaViewer .modal` era la segunda copia que
 * `CssRulesAreWrittenOnceTest` persigue, y el día que las dos declaren lo mismo
 * gana la de abajo sin que se vea leyendo. */
#mediaViewer[data-dialog-state~="open"] .modal {
  transform: none;
  opacity: 1;
}

#mediaViewer[data-dialog-state~="open"] .mviewer__bar,
#mediaViewer[data-dialog-state~="open"] .cz-thumbs,
#mediaViewer[data-dialog-state~="open"] .mviewer__nav {
  opacity: 1;
}

/* ── EL SPINNER, sólo mientras la foto no está ──────────────────────────────
 * Canónico y discreto. `is-loading` lo pone el componente al cambiar de slide y
 * lo quita el `load` de la imagen: si ya estaba en caché, el navegador resuelve
 * `complete` al instante y **el spinner no llega a verse**, que es justo lo que
 * Adán describió. */
#mediaViewer .mviewer__stage::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 34px;
  height: 34px;
  margin: -17px 0 0 -17px;
  border: 3px solid var(--color-border-2, #c8cde0);
  border-top-color: var(--color-accent, #4f7cff);
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  transition: opacity 140ms ease;
}
#mediaViewer.is-loading .mviewer__stage::after {
  opacity: 1;
  animation: mviewerSpin .7s linear infinite;
}
#mediaViewer.is-loading .mviewer__img { opacity: .25; }

@keyframes mviewerSpin { to { transform: rotate(360deg); } }

@media (prefers-reduced-motion: reduce) {
  #mediaViewer .modal,
  #mediaViewer .mviewer__bar,
  #mediaViewer .cz-thumbs,
  #mediaViewer .mviewer__nav {
    transition: none;
    transform: none;
    opacity: 1;
  }
  #mediaViewer.is-loading .mviewer__stage::after { animation: none; }
}

:fullscreen .mviewer__bar,
:-webkit-full-screen .mviewer__bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 3;
  border: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, .72), rgba(0, 0, 0, 0));
  color: #fff;
}
/* ── LOS BOTONES, ADAPTADOS AL CONTEXTO OSCURO ──────────────────────────────
 * 🔴 `btn--ghost` aclara su fondo al pasar el ratón, y con `color: #fff` encima
 * el resultado era **blanco sobre blanco**: el botón desaparecía justo al ir a
 * pulsarlo. Sobre una foto no vale heredar los estados del panel claro — el
 * fondo aquí no es una superficie, es la imagen de alguien.
 * Vidrio translúcido oscuro, como los controles de YouTube o Apple Fotos: se
 * lee sobre foto clara y sobre foto oscura, que es la única forma de acertar
 * sin saber qué foto habrá. */
#mediaViewer:fullscreen .mviewer__bar .btn,
#mediaViewer:-webkit-full-screen .mviewer__bar .btn {
  color: #fff;
  background: rgba(255, 255, 255, .14);
  border-color: rgba(255, 255, 255, .22);
  backdrop-filter: blur(6px);
}
#mediaViewer:fullscreen .mviewer__bar .btn:hover,
#mediaViewer:-webkit-full-screen .mviewer__bar .btn:hover {
  background: rgba(255, 255, 255, .28);
  color: #fff;
}
#mediaViewer:fullscreen .mviewer__bar .btn:focus-visible,
#mediaViewer:-webkit-full-screen .mviewer__bar .btn:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

/* Y la línea que separaba la barra: en el diálogo normal ordena; sobre una foto
 * es un trazo claro cruzando la imagen. */
:fullscreen .mviewer__title,
:fullscreen .mviewer__sub,
:fullscreen .mviewer__count,
:-webkit-full-screen .mviewer__title,
:-webkit-full-screen .mviewer__sub,
:-webkit-full-screen .mviewer__count { color: #fff; }

#mediaViewer:fullscreen .cz-thumbs,
#mediaViewer:-webkit-full-screen .cz-thumbs {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 64px;
  z-index: 3;
  background: transparent;
}

.mviewer__stage {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: min(64vh, 560px);
  background: #0b0b0d;          /* Un comprobante se lee mejor sobre oscuro, y
                                   deja de competir con el fondo del panel. */
}

/* 🔴 CENTRAR EL SLIDE, y sin esto la foto sale pegada arriba.
   El escenario es un flex que centra a sus hijos, pero desde que se pintan
   TODOS los slides su hijo directo es el `.cz-slide` —`position:absolute;
   inset:0`—, que se sale del flujo y deja de obedecer a ese centrado. Es el
   slide quien tiene que centrar lo suyo. Lo vio Adán: la imagen arriba y un
   hueco negro debajo. */
#mediaViewer .mviewer__stage .cz-slide {
  display: flex;
  align-items: center;
  justify-content: center;
}

.mviewer__img {
  max-width: 100%;
  max-height: min(64vh, 560px);
  object-fit: contain;           /* NUNCA `cover`: recortar un tiquet puede
                                    esconder justo el importe. */
  display: block;
  /* La entrada va por `@keyframes` y NO por una clase que alguien conmute: el
     `:key` del visor recrea el `<img>` al cambiar de comprobante, y una
     animación se re-ejecuta sola cuando el elemento nace. Con una clase
     condicional habría que acordarse de quitarla y volver a ponerla —y además
     `:class="open ? …"` deja escrito `open` donde un guard de CSS busca clases,
     que es como este cambio se puso rojo la primera vez. */
  animation: receiptIn .22s ease both;
}

@keyframes receiptIn {
  from { opacity: 0; transform: scale(.98); }
  to   { opacity: 1; transform: none; }
}

.mviewer__nav {
  position: absolute;
  /* 🔴 POR ENCIMA DE LOS SLIDES, y sin esto el botón NO FUNCIONA.
     `.cz-ax.navInNext` sube a `z-index: 1001` durante la transición y el slide
     activo lleva `pointer-events: auto`, así que un botón sin capa declarada
     queda debajo y **el clic se lo come la imagen**. No daba ningún error: el
     botón se veía, se podía pulsar y no pasaba nada — lo reportó Adán como «el
     next no funciona, tampoco se mueve cuando clicas». */
  z-index: 1010;
  top: 50%;
  transform: translateY(-50%);
  width: 44px;                   /* 44 px es el objetivo táctil mínimo de las
                                    guías de Apple, y aquí se pulsa con el pulgar. */
  height: 44px;
  border-radius: 50%;
  border: 0;
  display: grid;
  place-items: center;
  cursor: pointer;
  color: #fff;
  background: rgba(0, 0, 0, .45);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  transition: background .16s ease, transform .16s ease;
}

.mviewer__nav:hover  { background: rgba(0, 0, 0, .7); }
.mviewer__nav:active { transform: translateY(-50%) scale(.92); }
.mviewer__nav--prev  { left: var(--space-3, 12px); }
.mviewer__nav--next  { right: var(--space-3, 12px); }
.mviewer__nav[disabled] { opacity: .25; cursor: default; }

/* 📱 A PANTALLA COMPLETA EN EL MÓVIL, sin bordes.
   Lo pidió Adán: *«en un móvil, para aprovechar todo el espacio, yo expandiría
   el dialog a pantalla completa; así se aprovecha mejor y las miniaturas son
   alcanzables desde el dedo»*. Tiene razón por partida doble: un tiquet se lee
   por el detalle —cada píxel cuenta— y una tira de miniaturas encajonada en un
   diálogo con márgenes deja los cuadros por debajo del objetivo táctil. Es lo
   que hacen Fotos y WhatsApp al abrir una imagen. */
/* ✅ Las reglas de «ocupar la pantalla» ya NO viven aquí: son `.modal--fullscreen`
   y `.overlay--fullscreen`, arriba en esta hoja. Estaban escritas a medida para
   esta pantalla mientras la clase genérica existía SIN consumidores — dos
   mecanismos para una cosa, con el bueno muerto. Se unificaron el 2026-09-09 al
   pedirlas también el editor de imagen. Aquí queda sólo lo que es de ESTA
   pantalla. */
@media (max-width: 768px) {
  /* 📌 Aquí vivía `.mviewer .modal { max-width: 100% }`, que compensaba el ancho
     de ESCRITORIO (`min(940px, 96vw)`) para que en el móvil no se quedara en
     346 px de 360. Ya no hace falta: ese `max-width` y el `none` de la regla de
     arriba eran DOS COPIAS del mismo selector con distinta especificidad, y al
     fundirlas (2026-10-09) ganó `none`, que es lo que ya ganaba. Sin el tope no
     hay nada que compensar. */

  .mviewer__stage {
    flex: 1;
    min-height: 0;          /* sin esto, un hijo flex no encoge y desborda */
  }

  .mviewer__img { max-height: 100%; }

  /* Miniaturas alcanzables con el pulgar: 44 px es el objetivo táctil mínimo
     de las guías de Apple, y aquí se navega andando por la calle. */
  .cz-thumbs { padding: var(--space-2, 8px); }
  .cz-thumb  { width: 60px; height: 46px; }

  /* El botón de pantalla completa no se ofrece aquí: el visor YA ocupa la
     pantalla, así que pulsarlo no cambiaría nada y un control que no hace nada
     es ruido. Vivía en el `@media` de `.modal--fullscreen` de `cms.css`, o sea en
     la hoja del PANEL, y por eso el portal lo enseñaba. Es del componente. */
  .mviewer__fs { display: none; }
}

.mviewer__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3, 12px);
  padding: var(--space-3, 12px) var(--space-4, 16px);
  border-top: 1px solid var(--color-border, #dde1ee);
}

.mviewer__meta   { min-width: 0; }
.mviewer__title  {
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.mviewer__sub    { font-size: var(--text-xs, 11px); color: var(--color-muted, #6d7590); }
.mviewer__count  {
  font-size: var(--text-xs, 11px);
  color: var(--color-muted, #6d7590);
  font-variant-numeric: tabular-nums;   /* Que «3 / 12» no baile al pasar. */
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .mviewer__img { animation: none; }
  .mviewer__nav { transition: none; }
}


/* ═══════════════════════════════════════════════════════════════════════════
   MODO INMERSIVO — `{% include "partials/media-viewer.html" with {"immersive": true} %}`

   🗣️ Lo pidió la realidad: la directora de la Kita, usando el álbum de su
   centro, dijo *«ya no hace el efecto tan chulo»*. El visor había heredado el
   vestido del caso para el que nació —un COMPROBANTE de campo— y una foto de un
   niño no se mira igual que un tiquet.

   📐 Esto REPLICA lo que el `_lightbox.html` de kita ya tenía probado antes de
   consumir esta pieza; no inventa nada. Las dos cosas que se perdieron al
   unificar fueron el TAMAÑO y el FONDO. La animación de paso no: `cz-ax` del
   carrusel son las mismas cuatro, con el mismo `.7s cubic-bezier(.7,0,.3,1)`.

   ⚠️ Todo vive bajo `.mviewer--immersive`, así que las pantallas que no la
   declaran —`/media`, los comprobantes de `fieldwork`— no cambian un píxel.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── 🎬 LA ENTRADA: EL ESCENARIO CRECE Y EL CONTENIDO ENTRA DESPUÉS ─────────
   ── 🗣️ El caso, descrito por Adán el 2026-10-09 ───────────────────────────

   *«No es fluido, es como cuando arrancas un coche con motor pequeño y sientes
   —ves, en este caso— que le cuesta, y luego hace la transición y no se siente.
   ¿Es posible que haya que reducir la velocidad? Tampoco está aplicando el
   efecto zoom-in/out: abre con fade-in|out.»*

   📐 **Las dos mitades de su queja estaban medidas y tenía razón en las dos:**

   | | antes | por qué se nota |
   |---|---|---|
   | el diálogo | `scale(.94)` en `.26s cubic-bezier(.22,1,.36,1)` | **6 %** de recorrido, y esa curva gasta el 80 % en el primer 20 % del tiempo: un tirón y después nada |
   | la foto | `receiptIn .22s` desde `scale(.98)` | **2 %**, imperceptible — y **a la vez** que el diálogo |

   O sea: las dos se mueven juntas, las dos son casi inmóviles, y lo único que el
   ojo percibe es la opacidad. **«Abre con fade» es exactamente lo que pasaba.**

   ── Lo que hace su referencia, y es lo que da el «app-like» ────────────────

   Su modelo es el morph de Codrops/Tympanus (`ButtonComponentMorph`), del que
   copió la forma: **el contenedor escala y el contenido entra DESPUÉS, con
   fade**. Ese escalón es el efecto; sin él hay un solo gesto plano.

   Tres decisiones, y ninguna es un número inventado:

   1. **`scale(.9)` → 1**, o sea **10 %** en vez de 6: perceptible sin marear.
      Material usa `.8` en diálogos pequeños y iOS ~`.9` en los grandes; esto es
      un lienzo a pantalla completa, así que va al extremo suave de ese rango.
   2. **`cubic-bezier(.2,.8,.25,1)`**, y la curva se ELIGIÓ MIDIENDO LAS TRES,
      no por gusto. Muestreando la escala dentro de la página cada 30 ms:

      | curva | recorrido a mitad del tiempo | qué se siente |
      |---|---|---|
      | `.22,1,.36,1` + 6 % (lo que había) | **~95 % en 30 ms** | el tirón que describió Adán |
      | `.7,0,.3,1` + 10 % | **16 %** | el defecto contrario: tarda en arrancar |
      | **`.2,.8,.25,1` + 10 %** | **~72 %** | arranca decidido y frena largo |

      ⚠️ La segunda fue mi primer intento, por ser **la de `archive-x`** en este
      mismo visor. Era un argumento correcto aplicado al objeto equivocado: un
      *ease-in-out* pronunciado es lo que toca cuando algo SALE y algo ENTRA —un
      paso lateral—, no cuando una caja crece desde el centro. Para una apertura
      el canon es *decelerate* (Material 3, iOS), y es lo que la medición dijo.
      🔑 **Y el defecto original no era «ser ease-out»**: era 6 % de recorrido
      con una curva casi escalón. Con 10 % y una decelerada moderada, el ojo ve
      el camino.
   3. **`.42s`** y no `.26`: él pidió *«reducir la velocidad»* y es correcto —
      un recorrido mayor en el mismo tiempo se percibe como un salto, no como un
      movimiento.

   🔑 **Y la duración vive en SU PROPIA variable**, que es la regla de esta casa
   (`patrones/css-design-system.md` §«una `transition` compuesta NO sirve como
   delay»): el retardo del contenido se **deriva** de ella con `calc()`, así que
   cambiar `--mv-in-time` mueve las dos cosas a la vez y no queda un número viejo
   debajo. Es el mismo defecto que costó los 80 ms del acordeón.

   ⚠️ **`#mediaViewer.mviewer--immersive`** y no `.mviewer--immersive` a secas:
   la regla base es `#mediaViewer .modal` (1,1,0) y una clase sola pierde contra
   el id. El estado abierto lleva las dos clases (1,3,0) para ganarle también a
   `#mediaViewer[data-dialog-state~="open"] .modal`. Ya se pagó una vez escribir tres clases sin
   contar: el `z-index` del slide que le ganaba a `navInNext`.

   ⚖️ Todo bajo la variante, así que `/media` y los comprobantes de `fieldwork`
   **no cambian un píxel** — el visor nació para tiquets y un tiquet no necesita
   entrar con fuerza (`contrato-base.md` §14). */
#mediaViewer.mviewer--immersive {
  --mv-in-time: .42s;
  --mv-in-ease: cubic-bezier(.2, .8, .25, 1);
  /* La salida va MÁS RÁPIDA que la entrada (0,6×) — canon de Material: entrar es
     descubrir y merece tiempo, salir es una decisión ya tomada. En su propia
     variable, no en un `calc()` repetido, porque la usan tres declaraciones. */
  --mv-out-time: calc(var(--mv-in-time) * .6);
}

#mediaViewer.mviewer--immersive .modal {
  transform: scale(.9);
  /* El velo se funde en la mitad del tiempo: lo que tiene que crecer es la caja,
     y un fondo que tarda lo mismo parece que arrastra. */

  /* 🔴 **`clip-path` SE NOMBRA AQUÍ, y encontrarlo costó tres intentos.**
   *
   * El morph abre el diálogo recortado al rect de la tarjeta y suelta el
   * recorte (`assets/css/morph.css`). Pero `transition` **se REEMPLAZA entera,
   * no se acumula**, así que la propiedad tiene que estar nombrada en la regla
   * que GANA — y la que gana es ésta, no la de arriba: **esta hoja declara la
   * transición del `.modal` en DOS sitios** y la segunda tiene más
   * especificidad. Medido preguntándole al navegador en vez de leyendo:
   * `transition-property` decía `transform, opacity` y la duración `0.42s`,
   * que es `--mv-in-time` — o sea mandaba ésta.
   *
   * Es la familia CSS escrita dos veces, otra vez: la copia de arriba no hace
   * nada en modo inmersivo y es invisible leyendo.
   *
   * La DURACIÓN viene de `--morph-dur`, que vive en la hoja del morph: aquí se
   * declara quién participa, no cuánto dura. */
  transition: transform var(--mv-in-time) var(--mv-in-ease),
              opacity calc(var(--mv-in-time) * .5) linear,
              clip-path var(--morph-dur, .42s) var(--morph-ease, cubic-bezier(.22, 1, .36, 1));
}

#mediaViewer.mviewer--immersive[data-dialog-state~="open"] .modal {
  transform: none;
  opacity: 1;
}

/* EL CONTENIDO, DESPUÉS. Sustituye a `receiptIn` —que es la entrada del
   comprobante y escalaba un 2 % a la vez que el diálogo— por un fade que
   ARRANCA cuando la caja ya ha hecho la mayor parte de su camino.
   Sigue siendo `@keyframes` y no una clase conmutada, por la razón que ya está
   escrita arriba: el `:key` recrea el `<img>` al pasar de foto, así que la
   animación se re-ejecuta sola cuando el elemento nace. */
@keyframes mviewerContentIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* ── 🎬 Y LA SALIDA, EN ORDEN INVERSO ──────────────────────────────────────
 *
 * 🗣️ Lo describió Adán el 2026-10-09 leyendo su propia referencia: *«para cerrar
 * hace fade-out y al concluir el fade-out, entonces el zoom-out»*. Eso tiene
 * nombre en el sector —**coreografía**: una transición escalonada cuya SALIDA
 * invierte el orden de la entrada, lo último que entra es lo primero que sale— y
 * es la mitad que faltaba: hasta ahora todo se apagaba a la vez.
 *
 * 🔑 **Y la salida va MÁS RÁPIDA que la entrada**, que es la otra regla del canon
 * (Material: *«exit is faster than enter»*; la razón es de producto, no estética:
 * entrar es descubrir y merece tiempo, salir es una decisión ya tomada y esperar
 * molesta). Aquí son **0,6×** la entrada, derivado de la misma variable para que
 * no haya un segundo número que se quede viejo.
 *
 * `is-closing` lo pone el componente y el diálogo ESPERA a que este fundido
 * termine antes de cerrar — por el hook `beforeClose` de `Dialog` (era el hook de
 * promesa de `modal.js` sobre `is-open`, hasta la 166ª), que
 * es de lo que esto depende y que propuso él mismo el mismo día. */
/* 🔴 Por KEYFRAMES y no por `transition`, y lo dijo la medición: la primera
 * versión era `animation: none; opacity: 0; transition: opacity …` y la foto
 * **saltaba a 0 en el primer frame** — quitar la animación (que con `both` fijaba
 * `opacity: 1`) y cambiar la propiedad en el mismo frame no deja al navegador un
 * valor de partida que interpolar. Una animación SUSTITUYE a la otra y declara su
 * `from`, así que sí hay recorrido. Y de paso queda simétrica con la entrada. */
#mediaViewer.mviewer--immersive.is-closing .mviewer__img {
  animation: mviewerContentOut var(--mv-out-time) ease-in both;
}

/* 🔑 Y LA CAJA SE VA AL 70 % DEL FADE — SOLAPANDO, que es lo que faltaba.
 *
 * Adán, describiendo su referencia: *«cuando lo cierras, el contenido hace
 * fade-out y cuando va a un 70 % entonces hace el zoom-out»*. La primera versión
 * de esto era **secuencia estricta** —se esperaba el 100 % del fundido y
 * entonces el JS quitaba `is-open`—, y por eso se sentía como dos pasos: lo que
 * hace fluido al original es el SOLAPE.
 *
 * 🔑 **Y lo hace el CSS con un `transition-delay`, no el JS**, que es la técnica
 * de su referencia: allí el escalonado vive en la hoja y el JavaScript sólo
 * escucha **cuál es la última propiedad de la secuencia**, que cambia con la
 * dirección — su `onEndTransitionFn` filtra `opacity` al abrir y
 * `width/height/left/top` al cerrar. Aquí la última al cerrar es `transform`.
 *
 * El retardo se deriva de `--mv-out-time` para que no haya un segundo número. */
#mediaViewer.mviewer--immersive.is-closing .modal {
  transform: scale(.9);
  opacity: 0;
  transition: transform var(--mv-out-time) var(--mv-in-ease) calc(var(--mv-out-time) * .7),
              opacity   var(--mv-out-time) linear            calc(var(--mv-out-time) * .7);
}

@keyframes mviewerContentOut {
  from { opacity: 1; }
  to   { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  #mediaViewer.mviewer--immersive.is-closing .mviewer__img { animation: none; opacity: 0; }
  #mediaViewer.mviewer--immersive.is-closing .modal { transition: none; }
}

/* 🚨 Y LA VARIANTE TAMBIÉN SE APAGA — si no, es MÁS ESPECÍFICA que el bloque de
   `prefers-reduced-motion` de arriba (`#mediaViewer .modal`, 1-1-0, contra estas
   1-2-0) y seguiría animando a quien ha pedido que no. No es cautela: es el mismo
   defecto de especificidad que este repositorio acaba de medir en `.row-leaving`,
   donde una regla más concreta se comía la de la clase — sólo que ahí dejaba sin
   animación a todos y aquí se la impondría a quien no la quiere. WCAG 2.3.3. */
@media (prefers-reduced-motion: reduce) {
  #mediaViewer.mviewer--immersive .modal {
    transition: none;
    transform: none;
  }
  .mviewer--immersive .mviewer__img { animation: none; }
}

/* El fondo: la propia foto, difuminada y un pelo más grande para que el blur no
   deje borde. `object-fit: cover` a propósito —aquí sí—: es decoración, y lo que
   tiene que hacer es LLENAR. La foto de verdad sigue en `contain`. */
.mviewer--immersive .mviewer__blur {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: blur(18px);
  transform: scale(1.06);
  opacity: .55;
  pointer-events: none;
  z-index: 0;
}

/* El escenario deja de ser una caja oscura y pasa a ser el sitio donde cabe la
   foto: sin padding —el aire lo pone el propio límite de la imagen— y en negro
   casi puro para que el difuminado mande. */
.mviewer--immersive .mviewer__stage {
  padding: 0;
  background: #07080b;
}

/* 🔑 EL NÚMERO QUE DE VERDAD SE PERDIÓ. Estaba en `min(64vh, 560px)`, que es un
   tope pensado para que un comprobante no se sirva más grande que su resolución
   útil. El lightbox que esto sustituyó usaba `86vh`, y es el valor que vuelve —
   no uno inventado. El `92vw` del original se queda igualmente como el ancho. */
.mviewer--immersive .mviewer__img {
  max-height: 86vh;
  max-width: 92vw;
  border-radius: 6px;
  position: relative;
  z-index: 1;

  /* 🎬 LA ENTRADA DEL CONTENIDO, y va AQUÍ y no en una segunda regla: la escribí
     aparte y `CssRulesAreWrittenOnceTest` la cazó — con razón, porque la copia de
     abajo gana por orden y arreglar la de arriba no haría nada.
     Sustituye a `receiptIn` (la del comprobante, que escala un 2 % a la vez que
     el diálogo) por un fade que ARRANCA cuando la caja ya ha hecho el 89 % de su
     camino: ése es el escalón que da la sensación de aplicación.
     Sigue en `@keyframes` y no en una clase conmutada por la razón ya escrita: el
     `:key` recrea el `<img>` al pasar de foto, así que se re-ejecuta sola. */
  /* 🔑 AL 70 % DEL ZOOM, no al 89 %. El número sale de la referencia de Adán y
     él lo describió mirándola: *«el botón se va agrandando, dentro no hay nada;
     cuando está como a un 70 % entonces aparece el contenido con fade-in»*. La
     primera versión entraba al 89 % y eso se siente como DOS PASOS en vez de un
     gesto: lo que hace fluido al original es que las dos cosas SOLAPAN. */
  animation: mviewerContentIn calc(var(--mv-in-time) * .62) ease-out both;
  animation-delay: calc(var(--mv-in-time) * .7);
}

/* 🔴 AQUÍ ESCRIBÍ `.mviewer--immersive .mviewer__stage .cz-slide { z-index: 1 }`
   Y ERA UN DEFECTO MÍO, cazado midiendo la especificidad: tres clases (0,3,0)
   le ganan a `.cz-ax.navInNext` (0,2,0), que sube a `z-index: 1001` DURANTE la
   transición para pasar por encima del que sale. O sea que mi regla, puesta
   para «asegurar» el orden, era justo lo que lo rompía.

   No hace falta nada: el fondo difuminado va en `z-index: 0` y los slides del
   carrusel ya resuelven su orden entre ellos (`.cz-ax.is-on` → 2, el entrante
   → 1001). Una capa que no se declara es una capa que no estorba. */

/* El difuminado es decoración y cuesta compositor: quien pide menos movimiento
   recibe el fondo plano, que es lo que esa preferencia significa. */
@media (prefers-reduced-motion: reduce) {
  .mviewer--immersive .mviewer__blur { display: none; }
}

/* ══════════════════════════════════════════════════════════════════════════
   🎬 MIENTRAS EL CLON VIAJA, ESTE VISOR NO SE PINTA
   ══════════════════════════════════════════════════════════════════════════

   📐 **Medido el 2026-10-12 con una CAPTURA y con el estilo calculado**, que es
   lo que faltaba toda la sesión: a los 266 ms, con `is-morphing` ya puesta, el
   velo seguía en `rgba(0,0,0,.6)`, el panel en `rgb(240,242,248)` y
   `.mviewer__img` en **opacidad 1**. O sea el visor estaba entero y encima
   crecía un recuadro pequeño: *«el visor aparece de golpe y le sale un parche»*.

   🔴 **Por qué las reglas genéricas de `morph.css` no llegaban: especificidad.**
   Allí se escriben como `.overlay[data-dialog-state~="open"][data-dialog-effect="morph"]` (0,3,0) y aquí el visor se
   declara con `#mediaViewer.dialog` (1,1,0) — **un id gana a tres clases**. Es el
   mismo tropiezo que este fichero ya documenta para `--immersive`, y lo repetí.

   🔑 **Por eso el estado se escribe AQUÍ y no allí:** `morph.css` declara el
   mecanismo para cualquier consumidor; cada visor tiene que apagar **sus** propias
   superficies, porque sólo él sabe con qué especificidad las encendió. El reparto
   de `contrato-base.md` §14 aplicado al CSS.

   ⚖️ La foto grande se oculta a propósito: durante el viaje lo que se ve es el
   CLON, y tener las dos encima es exactamente el fantasma que la 136ª cerró. Al
   acabar, el efecto retira el estado y todo vuelve con su transición.
   📌 Decía «`morph.js` retira `is-morphing`»: esa pieza se retiró en la 166ª y el
   estado lo marca ahora `Dialog` con `data-dialog-effect`/`data-dialog-state`.
   ══════════════════════════════════════════════════════════════════════════ */

#mediaViewer.dialog[data-dialog-effect="morph"] {
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

#mediaViewer.dialog[data-dialog-effect="morph"] .modal {
  background: transparent;
  box-shadow: none;
}

#mediaViewer.dialog[data-dialog-effect="morph"] .mviewer__img,
#mediaViewer.dialog[data-dialog-effect="morph"] .mviewer__blur {
  opacity: 0;
}
