/* ===========================================================
   Perigami Polyhedra Unfolder
   Tema heredado de EquirecTime — "consola de nave"
   =========================================================== */
@font-face {
  font-family: 'Orbitron';
  src: url('../assets/fonts/Orbitron.ttf') format('truetype');
  font-weight: 400 900;
  font-display: swap;
}

:root {
  /* Paleta de marca (ver assets/ui/paleta de colores.svg): azules oscuros
     de fondo/estructura, naranjas para el acento interactivo — el mismo
     reparto de color que usa el logotipo (azul = wordmark, naranja =
     subtítulo). --amber pasa a un azul medio (antes naranja) para que el
     acento naranja quede reservado sólo a lo interactivo/activo. */
  --bg: #0f1826;
  --fg: #dce6f2;
  --muted: #7894c1;
  --cyan: #dc883e;
  --cyan-2: #c0732f;
  --amber: #5a83c2;
  --line: rgba(90, 131, 194, 0.18);
  --line-soft: rgba(90, 131, 194, 0.10);
  --field: rgba(90, 131, 194, 0.05);
  --panel: rgba(15, 24, 38, 0.82);
  --glow: 0 0 18px rgba(220, 136, 62, 0.3);
  --mono: ui-monospace, "Cascadia Code", "Segoe UI Mono", "SF Mono", Consolas, "Liberation Mono", monospace;
  --display: 'Orbitron', var(--mono);
  /* Panel de sólidos/imágenes redimensionable arrastrando el borde (ver
     panels.js, initPanelResizer): --solid-panel-w es el ancho y --solid-cols
     la cantidad de columnas de .solid-grid (3 a 6) — los recalcula panels.js
     en cada arrastre; estos valores de acá son sólo el default antes de que
     corra el JS, o si no corre: 6 columnas al ancho máximo de panel. */
  --solid-panel-w: 584px;
  --solid-cols: 6;
  /* Los lee panels.js (initPanelResizer) para calcular cuántas columnas
     entran en el ancho del panel: si se cambian acá, la cuenta los sigue. */
  --solid-gap: 2px;
  --solid-pad-l: 8px;
  --solid-pad-r: 14px;
  /* 0 = panel cerrado/colapsado, 1 = abierto — animados en panels.js con
     easeInOutExpo al hacer clic en la flecha de cada pestaña (ver
     initPanelToggleAnim). El 1 de acá es sólo el estado antes de que corra
     el JS: los dos paneles arrancan abiertos (data-panel-open="true" en el
     HTML), igual que siempre. */
  --panel-open-t: 1;
  --viewer-open-t: 1;
  /* Color sólido compartido por el visor 3D y los dos paneles (lateral +
     inferior) — antes el visor era negro puro y los paneles un tinte
     translúcido distinto; ahora todos son la MISMA superficie plana, y del
     mismo #0f1826 que --bg (el fondo de la paleta de marca, ver assets/ui/
     paleta de colores.svg) — no un tinte aparte. */
  --surface: var(--bg);
  /* Ancho/alto de cada panel cuando está colapsado: ni 0 ni su tamaño
     completo — lo justo para que se siga viendo la muesca + flecha de su
     pestaña, nada del contenido de adentro (grilla de sólidos / toggles). */
  --panel-collapsed: 18px;
  /* Escala de toda la interfaz — ver html{font-size} más abajo. Tocar este
     único número agranda o achica texto e íconos a la vez. */
  --ui-scale: 1.15;
}
@media (max-width: 640px) {
  /* En móvil el panel lateral ocupa casi toda la pantalla al abrirse — sólo
     un resto de visor 3D asoma al fondo, como recordatorio de que sigue ahí. */
  :root { --solid-panel-w: 95vw; }
}
* { box-sizing: border-box; }

html { color-scheme: dark; }
html, body {
  margin: 0; height: 100%; width: 100%;
  background: var(--bg); color: var(--fg);
  font-family: var(--mono);
  -webkit-font-smoothing: antialiased;
  overscroll-behavior: none;
}
body { display: flex; flex-direction: column; overflow: hidden; }

/* Escala global de la interfaz.
   Antes esto era `body { zoom: 1.15 }`. `zoom` es cómodo pero no es CSS
   estándar de toda la vida (Firefox recién lo soporta desde la 126) y no
   respeta el tamaño de letra que el usuario haya configurado en su navegador
   —que es justo lo que hay que respetar cuando alguien necesita texto más
   grande—. Acá la escala sale de agrandar la raíz: 115% de LO QUE EL USUARIO
   TENGA puesto, no de un 16px fijo. Todos los font-size de esta hoja están en
   rem, así que el resultado visual es el mismo que daba el zoom (Npx × 1.15),
   pero además crece si el usuario sube el tamaño de letra del navegador. */
html { font-size: calc(100% * var(--ui-scale)); }

/* ─── Header ─────────────────────────────────────── */
header {
  flex: 0 0 auto;
  padding: 8px 14px 7px;
  border-bottom: 1px solid var(--line);
  display: flex; align-items: center; gap: 10px;
  position: relative;
}
.header-left {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-width: 0;
  flex: 1 1 auto;
}
.header-brand { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 0 0 auto; }

/* Selector de idioma: en escritorio con más aire respecto del título que el
   resto de los huecos de la cabecera (ver margin-left más abajo); en móvil
   se separa al otro extremo de .header-left (ver el media query al final
   del archivo). */
.header-help { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; margin-left: 18px; }

/* El logo ES el botón "Acerca de". Reset total de apariencia de botón: sólo
   envuelve la imagen, sin bisel ni relleno propios. */
.btn-logo {
  position: relative;
  width: auto; padding: 0; flex: 0 0 auto;
  display: flex; align-items: center;
  background: transparent; clip-path: none;
  /* Los <button> traen su propio font-size chico por defecto en el user
     agent stylesheet — .brand-logo mide su alto en em (2.1em), así que sin
     esto heredaba ESE tamaño en vez del de .header-brand y el logo se veía
     achicado. */
  font: inherit;
}
.btn-logo::before { display: none; }
/* El glow del hover genérico de button (ver más abajo) no va acá: el logo se
   presenta limpio, sin resplandor en ningún estado. Hace falta :not(:disabled)
   para ganarle en especificidad a esa regla. */
.btn-logo:hover:not(:disabled) { filter: none; }

/* El logo crece apenas al pasar el mouse y se hunde al pulsarlo. :active
   cubre también el toque en móvil, donde no hay hover. */
.brand-logo { transition: transform .22s ease; }
.btn-logo:is(:hover, :focus-visible) .brand-logo { transform: scale(1.06); }
.btn-logo:active .brand-logo { transform: scale(.95); transition-duration: .08s; }

/* Selector de idioma: mismo interruptor pastilla+perilla que el resto de la
   app (.vt-sw), con "ES"/"EN" rotulados a los lados en vez de un texto que
   cambia — igual patrón que el toggle de 3 tiempos de wireframe (.vt-group
   --wire), pero de 2. Se resalta el lado activo, la perilla se corre. */
.vt-group--lang {
  /* Mismo gap que .vt-sw entre su track y su label (ver más abajo) — así el
     texto ES/EN queda a la misma distancia del interruptor que "Costuras",
     "Grilla" y el resto de los toggles con label adentro del botón. */
  flex: 0 0 auto; gap: 7px; padding: 3px 8px;
}
.vt-group--lang .vt-wire__lbl { font-size: 0.5625rem; font-weight: 700; }
.vt-group--lang[data-state="0"] .vt-wire__lbl:first-child,
.vt-group--lang[data-state="1"] .vt-wire__lbl:last-child {
  color: var(--cyan);
}
/* A diferencia del resto de los .vt-sw, este interruptor no es un on/off de
   verdad (siempre hay UN idioma elegido) — así que se ve "prendido" (pastilla
   y perilla iluminadas) en los DOS lados, sin importar cuál esté activo; sólo
   la POSICIÓN de la perilla (heredada de .is-on) indica cuál es. */
.lang-toggle .vt-sw__track {
  background: color-mix(in srgb, var(--cyan) 25%, var(--field));
  border-color: var(--cyan);
}
.lang-toggle .vt-sw__knob {
  background: var(--cyan);
  box-shadow: 0 0 7px var(--cyan);
}
/* Vistas predefinidas + "girar automáticamente": centradas de VERDAD en la
   cabecera entera, no en el hueco que dejan los lados (que son de anchos muy
   distintos: a la izquierda sólo el logo, a la derecha tres botones). Por eso
   se posicionan fuera del flujo, sobre el centro geométrico del header —
   ahora que "cargar imagen" se mudó al panel lateral, el lado izquierdo es
   angosto y esto vuelve a caber sin pisar nada. */
.header-center {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  display: flex; align-items: center; gap: 10px; flex: 0 0 auto;
}
/* gap:0 a propósito: el espacio entre botones lo pone el margin negativo de
   .output-tile + .output-tile (ver ahí el porqué), no esta propiedad — si
   los dos se suman, el hueco visible deja de ser el que se calculó. */
.header-output { display: flex; gap: 0; flex: 0 0 auto; margin-left: auto; }

/* En pantallas angostas no hay sitio para una sola fila — vuelve al flujo
   normal, cada bloque en su propia línea. Umbral más alto que el ancho
   "real" de pantalla que haría falta sin la escala global (ver --ui-scale):
   con todo un 15% más grande, el punto en el que deja de entrar en una sola
   línea también se corre más arriba. */
@media (max-width: 1150px) {
  header { flex-wrap: wrap; justify-content: center; }
  .header-center {
    position: static; transform: none;
    order: 3; flex-wrap: wrap; justify-content: center;
  }
  .header-left { order: 1; }
  .header-output { order: 2; margin-left: 0; }
}
/* El logotipo (perihedralogo_es/en.svg, ver #brandLogo en i18n.js) ya trae
   el nombre Y el subtítulo dibujados adentro — no hace falta texto propio
   acá, la imagen entera hace de título. */
header h1 { margin: 0; display: flex; align-items: center; }
@media (max-width: 640px) {
  .header-left { flex-wrap: nowrap; }
  /* Logo queda solo a la izquierda; el toggle de idioma se separa al otro
     extremo de la fila (el margin-left:18px de escritorio no hace falta
     acá, ya lo empuja el auto). */
  .header-help { margin-left: auto; }
  /* El logo compite con el toggle de idioma por la misma fila de
     .header-left (flex-wrap:nowrap acá arriba, así que si no entran se
     recortan en vez de saltar de línea) — se achica sólo él. */
  .brand-logo { height: 1.5em; }

  /* PDF/OBJ/PNG se bajan a una barra fija al pie del viewport — en el
     header (aunque ya reflowed, ver el @media de 1150px) competían por
     espacio con el logo y quedaban chicos. Alineados a la derecha (no
     centrados): es la mano con la que más se llega a tocar cómodo en un
     celular sostenido normalmente. Alto = padding + los 36px del propio
     botón (--tile-h); <main> reserva ese mismo hueco con padding-bottom
     más abajo, para que el visor 3D no quede tapado detrás. */
  .header-output {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 15;
    margin-left: 0; justify-content: flex-end;
    padding: 8px 10px calc(8px + env(safe-area-inset-bottom, 0px));
    background: var(--surface); border-top: 1px solid #884c18;
  }
  main { padding-bottom: calc(52px + env(safe-area-inset-bottom, 0px)); }
  /* La pestaña del panel de controles sube por encima de la barra fija de
     salida: sin esto las dos caen en la misma franja al pie y la barra tapa
     justo la muesca con la que se abre el panel. */
  .panel-tab--h { bottom: 6px; }
}
.brand-logo { height: 2.1em; width: auto; display: block; }

/* Imagen + sólido, arriba del todo en el panel lateral: es el primer paso
   del flujo (elegir qué proyectar) y ahí tiene el ancho del panel para sí,
   en vez de competir por el de la cabecera. */
.panel-image {
  display: flex; flex-direction: column; gap: 4px; min-width: 0;
  padding-bottom: 5px; margin-bottom: 5px;
  border-bottom: 1px dashed var(--line);
}
.panel-image .progress { width: 100%; }
.panel-image .progress-track { min-width: 36px; }

/* Encabezado de cada sección del panel — más notorio que .solid-grid__glabel
   (Orbitron en vez del mono, naranja en vez del cyan de acento secundario)
   para que "Cargar Imagen"/"Cargar Sólido" se lean como el título de cada
   sección, un escalón por encima de las etiquetas de grupo de adentro. */
.panel-section-label {
  display: block;
  font-family: var(--display); font-weight: 400; font-size: 0.71875rem;
  letter-spacing: 1px; text-transform: uppercase; color: var(--amber);
  margin: 0 0 4px;
}

/* ─── Main layout ────────────────────────────────── */
/* El sólido geométrico vuelve a elegirse en un panel lateral fijo — pero
   desplegable/replegable (botón de pestaña + tecla P, ver app.js), así en
   pantallas angostas se puede ocultar sin perder el acceso rápido de un
   panel permanente que sí tenía el modal/menú de antes. */
main {
  display: flex; flex: 1 1 auto; min-height: 0;
  position: relative;   /* referencia para el panel y la pestaña, ambos absolutos */
}

/* Panel de sólidos: OVERLAY encima de #viewer, no un participante del flex
   de main — si empujara a #viewer (como antes) el panel inferior, que es
   HIJO de #viewer, cambiaría de ancho y recentraría su contenido cada vez
   que este panel abre/cierra. Absoluto: #viewer siempre mide lo mismo, pase
   lo que pase acá. Ancho fijo cuando está abierto, colapsa a una tira
   angosta cuando no (--panel-collapsed: lo justo para que quepan la muesca
   y la flecha de la pestaña, nada de la grilla — el contenido se tapa con
   overflow:hidden + opacity:0 en .solid-panel__body, no con ancho 0). */
/* Ancho: interpolado por --panel-open-t (0 cerrado, 1 abierto), NO por una
   transition de CSS — initPanelToggleAnim() en panels.js anima esa variable
   cuadro a cuadro con easeInOutExpo al hacer clic en la flecha, así el
   abrir/cerrar tiene el mismo golpe de aceleración marcado que el resto de
   las animaciones cortas de la app (ver easeInOutExpo en uiKit.js) en vez
   del ease parejo por defecto del navegador. El mismo --panel-open-t mueve
   TRES elementos a la vez (este ancho, el left de .panel-tab--v y el de
   .panel-resizer) — de ahí que viva en una variable compartida y no en el
   estilo de cada uno. */
.solid-panel {
  position: absolute; top: 0; bottom: 0; left: 0; z-index: 5;
  width: calc(var(--panel-collapsed) + var(--panel-open-t, 1) * (var(--solid-panel-w) - var(--panel-collapsed)));
  overflow: hidden;
  /* La línea se ve SIEMPRE, abierto o colapsado: es la línea en la que
     encaja la muesca de la pestaña. Con border-color:transparent al
     colapsar (como estaba antes), la muesca quedaba dibujada en el aire,
     sin línea que la continuara arriba y abajo. */
  /* Marrón de marca (#884c18), el mismo del polígono decorativo del
     logotipo — no var(--line): acá se pidió a propósito ese tono fijo, no
     el color de línea genérico del resto de la interfaz. */
  background: var(--surface); border-right: 1px solid #884c18;
}
.solid-panel__body {
  width: var(--solid-panel-w); height: 100%;
  /* Más aire a la derecha que a la izquierda: el contenido quedaba pegado al
     borde del panel — a la izquierda ya lo separa el propio borde del panel
     lateral, pero a la derecha no había nada. */
  padding: 7px var(--solid-pad-r) 7px var(--solid-pad-l);
  overflow-y: auto; overflow-x: hidden;
  opacity: 1; transition: opacity .15s ease;
  /* Mismo marrón que border-right de .solid-panel (#884c18), no var(--line):
     así, si el contenido no cabe, la barra se lee como una continuación del
     borde del panel y no como un elemento de scroll genérico aparte. El
     ancho del hijo (var(--solid-panel-w), igual al ancho declarado del
     PADRE, que es border-box) se pasa por 1px del content-box del padre —
     lo justo para que la barra caiga pegada a esa línea, no separada de ella. */
  scrollbar-width: thin; scrollbar-color: #884c18 transparent;
}
main[data-panel-open="false"] .solid-panel__body { opacity: 0; pointer-events: none; }
.solid-panel__body::-webkit-scrollbar { width: 6px; }
.solid-panel__body::-webkit-scrollbar-track { background: transparent; }
.solid-panel__body::-webkit-scrollbar-thumb { background: #884c18; border-radius: 3px; }

/* ─── Pestañas de abrir/cerrar (panel lateral + panel inferior) ──── */
/* Una muesca cortada en la línea divisoria, con una flecha adentro — en vez
   del botón rectangular de antes. El "corte" es la propia línea de contorno
   (.panel-tab__line, stroke var(--line)) desviándose hacia adentro del panel
   y volviendo; el fondo sólido (--surface, el mismo color del visor Y de los
   paneles, ver :root) tapa lo que haya detrás para que se vea como un hueco
   de verdad, no un ícono flotando encima. */
/* Sin transition de left/bottom: ambas posiciones dependen de variables que
   ya vienen animadas cuadro a cuadro desde JS (--panel-open-t/
   --viewer-open-t en panels.js, o el propio --solid-panel-w mientras se
   arrastra el borde) — una transition acá encima sólo metería un segundo
   suavizado atrás del primero, y el resultado se ve con vibración/retraso
   en vez de un solo movimiento parejo. */
/* El fondo opaco y el recorte de la muesca viven en ::before, NO en el propio
   botón: clip-path recorta también el área sensible al puntero, así que con el
   recorte sobre el botón el blanco de clic real serían los ~13×50 px visibles
   y no los 24×72 de la caja. Separándolos, la caja se puede agrandar sin
   deformar ni correr el dibujo — ver --tab-hit-* más abajo. */
.panel-tab {
  position: absolute; z-index: 6; padding: 0;
  background: transparent;
  clip-path: none;
  width: var(--tab-hit-w, var(--tab-w));
  height: var(--tab-hit-h, var(--tab-h));
}
.panel-tab::before {
  content: '';
  position: absolute; z-index: -1;
  inset: auto; top: 50%; left: 50%;
  width: var(--tab-w); height: var(--tab-h);
  transform: translate(-50%, -50%);
  background: var(--surface);
  clip-path: var(--tab-clip);
}
.panel-tab__line { stroke: #884c18; }
/* Se espeja la FLECHA con scale(±1) (ver --chev-sx/--chev-sy más abajo), no el
   <svg> entero: la muesca siempre entra hacia el panel, y espejar todo la
   sacaría fuera del recorte dejándola apuntando al visor. */
.panel-tab__chevron {
  stroke: #884c18; stroke-width: 2;
  transition: stroke .15s, transform .1s;
  transform-box: fill-box; transform-origin: center;
  transform: scale(calc(var(--chev-sx, 1) * .78), calc(var(--chev-sy, 1) * .78));
}
/* :hover nativo, sin JS: "cruzar" el resaltado con la manija (.panel-resizer)
   vía una clase en <main> se traba o se dispara de más. El costo es que pasar
   el mouse por la muesca no enciende también la manija de al lado. */
.panel-tab:hover .panel-tab__chevron { stroke: #fff; }
/* El botón base anima :hover con un glow y :active con translateY(1px): el
   glow no pega con esta pestaña chica, y el translateY pisaría el transform
   que la centra sobre el borde. Acá no se mueve ni brilla — sólo la flecha
   cambia de color y se achica al presionar. El :not(:disabled) iguala la
   especificidad de las reglas base de button, que si no ganarían. */
.panel-tab:hover:not(:disabled), .panel-tab:active:not(:disabled) { transform: var(--tab-pos); filter: none; }
.panel-tab:active:not(:disabled) .panel-tab__chevron {
  stroke: #fff;
  transform: scale(calc(var(--chev-sx, 1) * .78 * .82), calc(var(--chev-sy, 1) * .78 * .82));
}

/* Vertical (panel lateral): centrada en alto, alineada con el borde del
   panel — translate(-50%,-50%) para que el centro del ícono (donde cae la
   línea) quede exactamente en `left`, no su esquina. Se corre en X junto
   con el ancho del panel al abrir/cerrar. clip-path: el fondo opaco llega
   apenas 1px a la derecha de la línea, lo justo para no cortar el trazo de
   la muesca sin tapar la sombra del panel. Esa zona opaca tapa el
   border-right del panel, así que el SVG dibuja TAMBIÉN los tramos rectos
   (y=0 a la muesca, y de ahí a y=72) del mismo var(--line) — si no, la
   línea se vería cortada justo antes y después de la muesca. */
.panel-tab--v {
  --tab-w: 24px; --tab-h: 72px;
  --tab-clip: inset(11px 11px 11px 0);
  --tab-pos: translate(-50%, -50%);
  top: 50%;
  /* Mismo --panel-open-t que .solid-panel (ver ahí el porqué): se corre en X
     junto con el ancho real del panel, cuadro a cuadro de la animación. */
  left: calc(var(--panel-collapsed) + var(--panel-open-t, 1) * (var(--solid-panel-w) - var(--panel-collapsed)));
  transform: var(--tab-pos);
}
main[data-panel-open="false"] .panel-tab--v { --chev-sx: -1; }

/* Horizontal (panel inferior): centrada en ancho, ANCLADA al borde inferior
   de .viewer-stage (no del panel: como .viewer-stage es flex:1 1 auto, su
   borde inferior YA está siempre justo en el límite con .viewer-panel, sea
   cual sea la altura de éste — no hace falta saber esa altura a mano). Mismo
   criterio de clip-path que la vertical, girado 90°. */
.panel-tab--h {
  --tab-w: 72px; --tab-h: 24px;
  /* Acá el recorte va ARRIBA (no abajo como en la vertical): la muesca entra
     hacia el panel, que está debajo — lo que hay que dejar sin tapar es el
     visor 3D, que está encima. */
  --tab-clip: inset(11px 11px 0 11px);
  --tab-pos: translate(-50%, 50%);
  /* z-index menor que .solid-panel (5): con el panel lateral abierto y una
     pantalla angosta (en móvil ocupa 95vw) esta pestaña quedaría dibujada
     ENCIMA del panel, flotando sobre la grilla de sólidos. */
  bottom: 0; left: 50%; transform: var(--tab-pos); z-index: 4;
}
#viewer[data-panel-open="false"] .panel-tab--h { --chev-sy: -1; }

/* Con el dedo, la muesca visible (13px de ancho la vertical, 13 de alto la
   horizontal) es imposible de acertar. Se agranda SÓLO la caja del botón en
   la dimensión angosta de cada una — la otra ya pasa de sobra — hasta los
   44px que recomiendan las guías táctiles. El dibujo no se mueve ni cambia
   de tamaño: vive en ::before, centrado y con su propio tamaño fijo.
   (pointer: coarse) y no un ancho de pantalla: el problema es el dedo contra
   el mouse, no cuántos píxeles mide el viewport — así también entra una
   tablet ancha con pantalla táctil. */
@media (pointer: coarse) {
  .panel-tab--v { --tab-hit-w: 44px; }
  .panel-tab--h { --tab-hit-h: 44px; }
}

/* ─── Redimensionar el panel lateral ───────────────── */
/* Franja angosta centrada en el mismo borde que .panel-tab--v: un poco más
   ancha que la línea (fácil de agarrar con el mouse) pero invisible en
   reposo, con un trazo cyan que aparece recién al pasar el mouse o arrastrar
   —así no compite visualmente con la pestaña de abrir/cerrar, que ocupa el
   mismo borde—. El arrastre en sí vive en panels.js (initPanelResizer). */
.panel-resizer {
  position: absolute; top: 0; bottom: 0;
  /* Mismo --panel-open-t que .solid-panel/.panel-tab--v — los tres se mueven
     juntos, cuadro a cuadro, con la animación de panels.js. */
  left: calc(var(--panel-collapsed) + var(--panel-open-t, 1) * (var(--solid-panel-w) - var(--panel-collapsed)));
  width: 10px; margin-left: -5px; z-index: 5;
  cursor: ew-resize; touch-action: none;
}
main[data-panel-open="false"] .panel-resizer { display: none; }
.panel-resizer::after {
  content: ''; position: absolute; top: 0; bottom: 0; left: 50%;
  width: 2px; transform: translateX(-50%);
  background: transparent; transition: background .15s;
}
.panel-resizer:hover::after, .panel-resizer:focus-visible::after, .panel-resizer.is-dragging::after {
  background: var(--cyan); box-shadow: var(--glow);
}
.panel-resizer:focus-visible { outline: none; }
/* Mientras se arrastra: nada de transiciones (se sentirían con retraso
   detrás del mouse) ni selección de texto accidental por debajo del cursor. */
body.is-resizing-panel { cursor: ew-resize; user-select: none; }
body.is-resizing-panel .solid-panel { transition: none; }
/* En móvil el panel es casi toda la pantalla (--solid-panel-w: 95vw, ver
   :root) — arrastrarlo no tiene sentido ahí, ver el guard de mobile en
   initPanelResizer(). */
/* Con puntero grueso tampoco: la manija mide 10px y la pestaña de abrir/
   cerrar, ya agrandada a 44px para el dedo, le queda encima (z-index 6 vs 5).
   Dejarla visible sería una franja que no responde nunca. */
@media (max-width: 640px), (pointer: coarse) { .panel-resizer { display: none; } }

/* Salida (PDF / OBJ / PNG), en la cabecera: ícono + texto en línea, compacto.
   Fondo naranja sólido (no el tinte translúcido de otros botones): PDF es
   el de mayor jerarquía (#c0732f, el naranja más vivo de la paleta), OBJ/PNG
   van en el marrón (#884c18) — los dos con texto oscuro (#0f1826) encima.

   Trapezoidales, alternando el sentido: PDF con la base ancha abajo, OBJ con
   la base ancha arriba, PNG otra vez ancha abajo pero con el lado derecho
   recto (no en punta, para no repetir tres veces la misma silueta seguida).
   --tile-slant es la altura del botón por tan(15°) — el lado inclinado
   queda a 15° de la vertical, casi recto. La altura se fija (no la decide
   el contenido) para que esa cuenta sea exacta, no una aproximación. */
.output-tile {
  flex: 0 0 auto; width: auto;
  flex-direction: row; align-items: center; gap: 6px;
  --tile-h: 36px;
  --tile-slant: calc(var(--tile-h) * tan(15deg));
  height: var(--tile-h);
  padding: 0 26px;
  background: #884c18; border: 1px solid #884c18;
  /* Azul más oscuro de la paleta, el mismo en los tres botones de salida. */
  color: #0f1826;
  transition: color .15s, border-color .15s, background .15s;
  clip-path: polygon(
    var(--tile-slant) 0, calc(100% - var(--tile-slant)) 0,
    100% 100%, 0 100%
  );
}
.output-tile::before { content: none; }
/* Junto al de al lado en vez de con el gap normal (6px): como el lado
   inclinado angosta el botón, ese mismo gap dejaba un hueco de gap+slant en
   V entre dos botones vecinos — mucho más de lo que se ve en la línea
   recta. El margen negativo los acerca hasta un huequito de ~3px parejo en
   toda la altura (gap real = margin + slant, así que margin = 3px - slant). */
.output-tile + .output-tile { margin-left: calc(3px - var(--tile-slant)); }
/* OBJ: mismo trapecio que PDF pero espejado en Y — base ancha ARRIBA. */
#btnOBJ {
  clip-path: polygon(
    0 0, 100% 0,
    calc(100% - var(--tile-slant)) 100%, var(--tile-slant) 100%
  );
}
/* PNG: base ancha abajo otra vez (como PDF), pero el lado derecho queda
   recto — sin inclinación — así no se repite la misma silueta tres veces. */
#btnPNG {
  clip-path: polygon(var(--tile-slant) 0, 100% 0, 100% 100%, 0 100%);
  padding-right: 18px;   /* del lado recto no hace falta tanto aire */
}
.output-tile span {
  font-size: 0.5625rem; letter-spacing: .8px; text-transform: uppercase; white-space: nowrap;
}
.output-tile:hover:not(:disabled) {
  filter: brightness(1.12); border-color: #884c18;
  background: #884c18; color: #0f1826;
}
/* PDF es el de mayor jerarquía: mismo rótulo oscuro, sobre el naranja vivo. */
.output-tile.primary {
  border-color: #c0732f; color: #0f1826;
  background: #c0732f;
}
.output-tile.primary:hover:not(:disabled) {
  background: #c0732f; filter: brightness(1.12) drop-shadow(0 0 7px rgba(192,115,47,.45));
}
.output-tile:disabled { opacity: .35; }

/* ─── Botones (bevel de dos capas del sistema) ───── */
button {
  --o: polygon(0 0, calc(100% - 9px) 0, 100% 9px, 100% 100%, 9px 100%, 0 calc(100% - 9px));
  --i: polygon(0 0, calc(100% - 8.4px) 0, 100% 8.4px, 100% 100%, 8.4px 100%, 0 calc(100% - 8.4px));
  --fill: var(--field);
  width: 100%; padding: 8px 10px;
  background: var(--brd, var(--line)); clip-path: var(--o);
  color: var(--fg); font-family: var(--mono);
  font-size: 0.65625rem; letter-spacing: 1.4px; text-transform: uppercase;
  cursor: pointer; transition: color .15s, filter .15s;
  display: flex; align-items: center; justify-content: center; gap: 7px;
}
button:hover:not(:disabled) {
  --brd: var(--cyan); --fill: rgba(220,136,62,0.14); color: var(--bg);
  filter: drop-shadow(0 0 7px rgba(220,136,62,.4));
}
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { opacity: .35; cursor: not-allowed; }

/* Primario: relleno cyan sólido, texto oscuro */
button.primary { --brd: var(--cyan); --fill: var(--cyan); color: var(--bg); }
button.primary:hover:not(:disabled) {
  --fill: var(--cyan); color: var(--bg);
  filter: brightness(1.12) drop-shadow(0 0 10px rgba(220,136,62,.55));
}

/* Borde uniforme en las esquinas cortadas: capa de relleno 1px hacia dentro */
button { position: relative; border: none !important; isolation: isolate; }
button::before {
  content: ""; position: absolute; inset: 1px; z-index: -1;
  clip-path: var(--i); background: var(--fill);
}

/* ─── Campos ─────────────────────────────────────── */
select {
  width: 100%; padding: 6px 9px;
  border: 1px solid var(--line); background: var(--field); color: var(--fg);
  font-family: var(--mono); font-size: 0.6875rem; letter-spacing: .5px;
  outline: none; appearance: none; -webkit-appearance: none; border-radius: 0;
  transition: border-color .15s, box-shadow .15s;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path fill='%23dc883e' d='M6 8 0 0h12z'/></svg>");
  background-repeat: no-repeat; background-position: right 10px center;
  padding-right: 26px; cursor: pointer;
}
select:focus { border-color: var(--cyan); box-shadow: var(--glow); }
select:disabled { opacity: .38; cursor: not-allowed; }
option { background: #0c1524; color: var(--fg); }

/* ─── Mosaico de sólidos (reemplaza el <select>) ──── */
/* Columnas dinámicas: --solid-cols (3 a 6) lo recalcula panels.js en cada
   arrastre del borde. Sólo cambia la CANTIDAD de columnas con el ancho; letra
   e ícono quedan fijos (ver .solid-tile__name/svg más abajo). El ancho de
   columna nunca se declara a mano: sale de dividir el panel en --solid-cols
   partes. Mismo mecanismo para #imageGrid, que comparte .solid-grid. La
   etiqueta de grupo ocupa la fila completa, así el grupo siguiente arranca
   alineado con los de arriba. */
.solid-grid { display: grid; width: 100%; grid-template-columns: repeat(var(--solid-cols, 5), 1fr); gap: var(--solid-gap); align-items: stretch; }
.solid-grid__glabel {
  grid-column: 1 / -1;
  display: flex; align-items: center; gap: 6px;
  font-size: 0.5625rem; font-weight: 700; letter-spacing: 1px;
  text-transform: uppercase; color: var(--cyan-2);
  margin-top: 2px;
}
.solid-grid__glabel:first-child { margin-top: 0; }
.solid-grid__glabel::after { content: ""; flex: 1; height: 0; border-top: 1px dashed var(--line); }
.solid-tile {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  width: auto; min-width: 0; padding: 2px 2px 3px; clip-path: none;
  background: transparent; border: 1px solid var(--line);
  color: var(--muted);
  transition: color .15s, border-color .15s, background .15s, transform .18s ease, box-shadow .18s ease;
}
.solid-tile::before { content: none; }
/* Tamaño fijo, sea cual sea el ancho de columna: una columna más ancha da más
   aire alrededor, no un ícono más grande. Lo que cambia con el ancho es
   cuántas palabras entran por línea del nombre, no el cuerpo de la letra. */
.solid-tile svg { width: 100%; height: auto; max-width: 30px; display: block; }
/* Mismo hueco que ocupará el <svg> una vez resuelto (ver _lazyIcons en
   solidGrid.js): sin esto, el ícono "aparecería" empujando el nombre hacia
   abajo apenas se resuelve, en vez de ya tener su lugar reservado. */
.solid-tile__icon-slot { display: block; width: 100%; max-width: 30px; aspect-ratio: 1; margin: 0 auto; }
.solid-tile__name {
  width: 100%; min-width: 0; font-size: 0.5625rem; line-height: 1.08; text-align: center;
  text-transform: none; letter-spacing: 0;
  /* Dos líneas en vez de una sola con "…": el nombre completo entra en la
     mayoría de los casos, y cuando no, el corte a las 2 líneas (line-clamp)
     se ve mejor que perder la mitad de la palabra a mitad de línea. */
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; white-space: normal; word-break: break-word;
  min-height: calc(1.08em * 2);
  color: var(--fg);
}
.solid-tile:hover:not(:disabled) {
  color: var(--fg); filter: none; border-color: var(--cyan);
  background: color-mix(in srgb, var(--cyan) 8%, var(--field));
  box-shadow: 0 4px 10px rgba(220, 136, 62, .2);
}
.solid-tile:active { transform: none; }
.solid-tile.is-active {
  border-color: var(--cyan);
  background: color-mix(in srgb, var(--cyan) 16%, var(--field));
  box-shadow: 0 0 0 1px var(--cyan);
}
.solid-tile.is-active .solid-tile__name { color: var(--cyan); }
.solid-tile:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }

/* ─── Mosaico de imágenes base (mismo tile que los sólidos) ──── */
/* .image-tile hereda tamaño y estados de .solid-tile; acá sólo lo que cambia:
   una miniatura apaisada en vez de un SVG calculado, y un ícono de carga. */
.image-tile__thumb {
  width: 100%; aspect-ratio: 2.5 / 1; overflow: hidden;
  border-radius: 2px; background: transparent;
  display: flex; align-items: center; justify-content: center;
}
.image-tile__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.image-tile__thumb--upload, .image-tile__thumb--none { color: var(--muted); }
.image-tile:hover .image-tile__thumb--upload,
.image-tile:hover .image-tile__thumb--none { color: var(--cyan); }
.image-tile.is-active .image-tile__thumb--upload,
.image-tile.is-active .image-tile__thumb--none { color: var(--cyan); }
/* "Cargar imagen" ensanchado: de la primera columna a la penúltima, dejando
   la última para "Ninguna". Funciona con cualquier cantidad de columnas
   (--solid-cols, 3 a 6) porque -2 siempre apunta a la penúltima. */
.image-tile--upload { grid-column: 1 / -2; justify-content: center; }
/* Ícono chico y fijo, no estirado por el ancho de VARIAS columnas: el
   thumb de una miniatura real usa aspect-ratio 2/1 sobre el 100% de su
   ancho a propósito, pero acá ese mismo ancho es el de toda la fila menos
   "Ninguna" — heredarlo dejaría esta fila mucho más alta que las demás. */
.image-tile__thumb--upload { width: 36px; height: 36px; aspect-ratio: auto; flex: 0 0 auto; }
.image-tile--upload.is-dragover {
  border-color: var(--cyan);
  background: color-mix(in srgb, var(--cyan) 12%, var(--field));
}

/* ─── Visor 3D ───────────────────────────────────── */
#viewer {
  flex: 1 1 auto; position: relative; min-height: 260px;
  background: var(--surface);
  display: flex; flex-direction: column;
  overflow: hidden;
}
@media (min-width: 820px) { #viewer { min-height: 0; } }

/* Sólido 3D + hint: la parte de #viewer que se encoge para dejarle sitio fijo
   al panel de controles de abajo. */
.viewer-stage {
  flex: 1 1 auto; min-height: 120px; position: relative;
  background: var(--surface);
  display: flex; align-items: center; justify-content: center;
}
#threeCanvas { width: 100%; height: 100%; display: block; cursor: grab; }
#threeCanvas:active { cursor: grabbing; }
#viewerHint {
  position: absolute; margin: 0;
  color: var(--muted); font-size: 0.625rem;
  letter-spacing: 1.5px; text-transform: uppercase;
  pointer-events: none; max-width: 80%; text-align: center; line-height: 1.7;
  text-shadow: 0 1px 4px rgba(0,0,0,.9);
}
#viewerHint.error {
  color: var(--amber); text-transform: none; letter-spacing: .5px;
  background: rgba(4, 9, 17, 0.9);
  border: 1px solid rgba(90, 131, 194, 0.4);
  padding: 10px 14px; pointer-events: auto; user-select: text;
}

/* ── Panel de controles del visor (toggles + slides + hints) ─────── */
/* Replegable como el panel de sólidos, pero colapsando en ALTO y a
   --panel-collapsed (no a 0): queda la tira justa para la muesca y la flecha
   de su pestaña. max-height y no height, para animar sin medir el alto real
   del contenido desde JS; el tope abierto (380px) es holgado a propósito —
   con el idioma en inglés y la ventana angosta la fila de toggles envuelve
   más, y un tope justo cortaría los hints de abajo. El contenido vive
   en .viewer-panel__inner y se atenúa aparte (opacity, no display): si no,
   asomaría un pedazo cortado de la primera fila de toggles. */
.viewer-panel {
  flex: 0 0 auto; overflow: hidden;
  /* Igual que .solid-panel con --panel-open-t: animado cuadro a cuadro por
     panels.js con easeInOutExpo (initPanelToggleAnim), no por una transition
     de CSS. --viewer-open-t es la propia de este panel (0 cerrado, 1
     abierto). */
  max-height: calc(var(--panel-collapsed) + var(--viewer-open-t, 1) * (380px - var(--panel-collapsed)));
  background: var(--surface);
  /* Igual que en .solid-panel: la línea se ve SIEMPRE, abierto o colapsado
     — es la línea en la que encaja la muesca de la pestaña. Mismo marrón de
     marca fijo (#884c18) que .solid-panel, no var(--line). */
  border-top: 1px solid #884c18;
}
.viewer-panel__inner {
  padding: 18px 14px 8px;
  opacity: 1; transition: opacity .15s ease;
}
#viewer[data-panel-open="false"] .viewer-panel__inner { opacity: 0; pointer-events: none; }

.vt-row {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 6px 14px; justify-content: center;
}
.vt-row + .vt-row { margin-top: 8px; }

.vt-sep {
  width: 1px; align-self: stretch; min-height: 16px;
  background: var(--line);
}

/* Agrupar no pinta: el reparto de la fila lo marcan los separadores (.vt-sep),
   no un fondo por grupo. Los .vt-group--* sólo ajustan espaciado. */
.vt-group {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 7px; padding: 3px 8px; border-radius: 3px;
}
.vt-group--poles { gap: 8px; }

/* Modo de vista 3D: aparte de los demás toggles (grupo propio, al final de la
   fila) y con los dos extremos siempre rotulados — "Wireframe" e "Imagen" no
   cambian de texto ni tamaño al clickear, sólo se resalta cuál lado está
   activo y se mueve la perilla del medio; nada más en la fila se reacomoda. */
.vt-group--wire { gap: 8px; }
.vt-wire__lbl {
  font-size: 0.5625rem; letter-spacing: .5px; text-transform: none;
  color: var(--muted); white-space: nowrap; transition: color .18s;
}
/* En "ambos" (estado central) las dos etiquetas están "prendidas" — ahí sí
   están las dos activas a la vez; en los extremos, sólo la que corresponde. */
.vt-group--wire[data-state="0"] .vt-wire__lbl:first-child,
.vt-group--wire[data-state="1"] .vt-wire__lbl,
.vt-group--wire[data-state="2"] .vt-wire__lbl:last-child {
  color: var(--amber); font-weight: 700;
}

/* Interruptor: pastilla + perilla, siguiendo el mismo lenguaje visual que el
   resto de la app (--cyan al activarse, con resplandor). */
.vt-sw {
  display: inline-flex; align-items: center; gap: 7px;
  background: transparent; border: none; cursor: pointer; padding: 2px 0;
  width: auto; clip-path: none;
  font-family: var(--mono); font-size: 0.625rem; letter-spacing: .3px;
  text-transform: none;
  color: var(--muted); transition: color .18s;
}
.vt-sw::before { content: none; }
/* display:inline-flex de arriba (una regla de autor) le gana al display:none
   por defecto del atributo hidden (regla de user-agent) si no se repite acá
   — mismo patrón que .vt-btn[hidden]/.progress[hidden] más abajo. Lo
   necesita #btnBoxFlaps (oculto en sólidos sin tapa/fondo, ver app.js). */
.vt-sw[hidden] { display: none; }
.vt-sw:hover:not(:disabled) { color: var(--fg); filter: none; background: transparent; }
.vt-sw:active { transform: none; }
.vt-sw__lbl { line-height: 1.2; white-space: nowrap; font-size: 0.59375rem; }
.vt-sw__track {
  position: relative; width: 27px; height: 13px; border-radius: 999px;
  background: var(--field); border: 1px solid var(--line);
  transition: background .2s ease, border-color .2s ease;
  flex-shrink: 0;
}
.vt-sw__knob {
  position: absolute; top: 1px; left: 1px; width: 9px; height: 9px; border-radius: 50%;
  background: var(--muted);
  transition: transform .2s cubic-bezier(.34,1.56,.64,1), background .2s ease;
}
.vt-sw.is-on { color: var(--fg); }
.vt-sw.is-on .vt-sw__track { background: color-mix(in srgb, var(--cyan) 25%, var(--field)); border-color: var(--cyan); }
.vt-sw.is-on .vt-sw__knob { transform: translateX(14px); background: var(--cyan); box-shadow: 0 0 7px var(--cyan); }
.vt-sw:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; border-radius: 3px; }

/* Interruptor de 3 tiempos (wireframe): pastilla más ancha, 3 paradas del
   mismo tamaño de paso (16px) que ya usa el de 2 — el centro (estado por
   defecto, "ambos") en cian; los dos extremos en ámbar, para que se lean
   como "modo especial" y no como un simple on/off. */
.vt-sw3 .vt-sw__track { width: 44px; }
.vt-sw3.is-s1 .vt-sw__track { background: color-mix(in srgb, var(--cyan) 25%, var(--field)); border-color: var(--cyan); }
.vt-sw3.is-s0 .vt-sw__track,
.vt-sw3.is-s2 .vt-sw__track { background: color-mix(in srgb, var(--amber) 22%, var(--field)); border-color: var(--amber); }
.vt-sw3.is-s0 .vt-sw__knob { transform: translateX(0);   background: var(--amber); box-shadow: 0 0 7px var(--amber); }
.vt-sw3.is-s1 .vt-sw__knob { transform: translateX(16px); background: var(--cyan);  box-shadow: 0 0 7px var(--cyan); }
.vt-sw3.is-s2 .vt-sw__knob { transform: translateX(32px); background: var(--amber); box-shadow: 0 0 7px var(--amber); }
.vt-sw3.is-s0, .vt-sw3.is-s1, .vt-sw3.is-s2 { color: var(--fg); }

/* Slides de posición: mismo ancho, valor numérico a la derecha de la etiqueta */
.vt-row--sliders { gap: 8px 28px; }
.vt-slider {
  display: flex; flex-direction: column; gap: 3px;
  width: min(220px, 44vw);
}
.vt-slider__lbl {
  font-size: 0.5625rem; letter-spacing: 1.2px; text-transform: uppercase;
  color: var(--muted);
  display: flex; align-items: center; gap: 2px;
}
.vt-slider__lbl b { color: var(--cyan); font-weight: 600; letter-spacing: 0; }
/* El valor numérico es clickeable: abre un campo para escribir el grado
   exacto, en vez de depender sólo de arrastrar el slide a ojo. */
.vt-slider__val {
  cursor: pointer; border-radius: 3px; padding: 1px 3px; margin: -1px -3px;
  transition: background .15s, color .15s;
}
.vt-slider__val:hover, .vt-slider__val:focus-visible {
  background: color-mix(in srgb, var(--cyan) 20%, transparent);
  outline: none;
}
.vt-slider__edit {
  width: 48px; padding: 1px 4px; margin-left: 2px;
  background: var(--field); border: 1px solid var(--cyan); color: var(--cyan);
  font-family: var(--mono); font-size: 0.625rem; font-weight: 600; letter-spacing: 0;
  border-radius: 3px; outline: none; text-align: right;
}
.vt-slider__edit::-webkit-outer-spin-button,
.vt-slider__edit::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.vt-slider__edit[type="number"] { -moz-appearance: textfield; appearance: textfield; }
.vt-slider input[type="range"] {
  -webkit-appearance: none; appearance: none; width: 100%; height: 4px;
  background: var(--field); border: 1px solid var(--line); outline: none; cursor: pointer;
  margin: 2px 0 0; border-radius: 999px;
}
.vt-slider input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; width: 13px; height: 13px;
  background: var(--cyan); border: 0; border-radius: 50%;
  box-shadow: var(--glow); cursor: pointer;
}
.vt-slider input[type="range"]::-moz-range-thumb {
  width: 13px; height: 13px; border: 0; border-radius: 50%;
  background: var(--cyan); box-shadow: var(--glow); cursor: pointer;
}
.vt-slider input[type="range"]::-moz-range-track {
  height: 4px; background: var(--field); border: 1px solid var(--line); border-radius: 999px;
}

/* Posición X / Rotación Y: largas, para poder ajustar fino arrastrando. Un
   escalón más discretas que Desplegar —el control protagonista del visor— por
   color y peso, no por ser cortas. */
.vt-row--sliders .vt-slider:not(.vt-slider--fold) { width: min(280px, 46vw); opacity: .82; }

/* Desplegar: más grande, en el mismo naranja que los toggles (--cyan), pero
   sigue viviendo en la misma fila que las otras dos — se destaca por
   tamaño/color, no por aislarse en su propia fila. */
.vt-slider--fold { width: min(260px, 48vw); }
.vt-slider--fold .vt-slider__lbl {
  font-size: 0.6875rem; font-weight: 700; letter-spacing: 1.5px; color: var(--fg);
}
.vt-slider--fold .vt-slider__lbl b { color: var(--cyan); font-size: 0.75rem; }
.vt-slider--fold input[type="range"] {
  height: 7px; border-color: var(--cyan); border-radius: 999px;
  background: color-mix(in srgb, var(--cyan) 12%, var(--field));
}
.vt-slider--fold input[type="range"]::-webkit-slider-thumb {
  width: 17px; height: 17px; background: var(--cyan);
  box-shadow: 0 0 12px rgba(220, 136, 62, .6);
}
.vt-slider--fold input[type="range"]::-moz-range-thumb {
  width: 17px; height: 17px; background: var(--cyan);
  box-shadow: 0 0 12px rgba(220, 136, 62, .6);
}
.vt-slider--fold input[type="range"]::-moz-range-track {
  height: 7px; background: color-mix(in srgb, var(--cyan) 12%, var(--field)); border-color: var(--cyan);
  border-radius: 999px;
}

/* Vistas predefinidas: botones cuadrados con ícono, mismo grupo visual que
   los slides de posición/rotación (misma fila, se leen como "parte de la
   misma herramienta" de encuadre de la textura). */
.vt-views { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.vt-view {
  width: 26px; height: 26px; padding: 0; flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  background: var(--field); clip-path: none;
  border: 1px solid var(--line);
  color: var(--muted); transition: color .15s, border-color .15s, background .15s;
}
.vt-view::before { content: none; }
.vt-view svg { fill: none; stroke: currentColor; stroke-width: 1.6; }
/* Los <svg> del HTML traen width/height en px fijos, así que no escalarían
   con --ui-scale (que actúa vía font-size). Dimensionarlos en em los ata al
   tamaño de letra de su botón y vuelven a crecer junto con el resto — es lo
   que antes hacía gratis el `zoom`. Sólo los íconos de chrome de la UI: los
   de .solid-tile y .panel-tab tienen su propio dimensionado a propósito. */
.vt-view svg,
.output-tile svg,
.vt-sw svg { width: 1.7em; height: 1.7em; }
.vt-view:hover:not(:disabled) {
  color: var(--cyan); border-color: var(--cyan);
  background: color-mix(in srgb, var(--cyan) 12%, var(--field));
  filter: none;
}
.vt-view:active { transform: translateY(1px); }
.vt-view:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
/* Con el dedo, 26px es poco. Se agranda el botón entero (no un área
   invisible por encima): son siete pegados con 4px de separación, y un área
   sensible más ancha que el botón se solaparía con la del vecino — el toque
   entre dos caería en cualquiera de los dos. .vt-views ya trae flex-wrap,
   así que si no entran en una línea bajan a la siguiente. */
@media (pointer: coarse) {
  .vt-view { width: 38px; height: 38px; }
}

/* Botones de ícono con estado (reflejar en X / en Y). Mismo cuadrado con
   borde que .vt-view, el lenguaje de botón de ícono que ya usa la app; lo que
   suman es el estado encendido, porque éstos sí recuerdan si están puestos. */
.vt-icon {
  width: 26px; height: 26px; padding: 0; flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  background: var(--field); clip-path: none;
  border: 1px solid var(--line);
  color: var(--muted); transition: color .15s, border-color .15s, background .15s;
}
.vt-icon::before { content: none; }
.vt-icon svg { fill: none; stroke: currentColor; stroke-width: 1.6; width: 1.7em; height: 1.7em; }
.vt-icon:hover:not(:disabled) {
  color: var(--cyan); border-color: var(--cyan);
  background: color-mix(in srgb, var(--cyan) 12%, var(--field));
  filter: none;
}
.vt-icon:active { transform: translateY(1px); }
.vt-icon:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
/* Encendido: relleno naranja y texto oscuro, el mismo par que usa .vt-sw.is-on
   para su pastilla — así "puesto" se lee igual sea interruptor o ícono. */
.vt-icon.is-on {
  color: var(--bg); background: var(--cyan); border-color: var(--cyan);
  box-shadow: var(--glow);
}
.vt-icon.is-on:hover:not(:disabled) { color: var(--bg); background: var(--cyan); }
@media (pointer: coarse) { .vt-icon { width: 38px; height: 38px; } }

/* Botón compacto de texto dentro de la fila de toggles ("Fijar polos",
   "Restaurar polos"): tiene que leerse como BOTÓN —algo que se pulsa para que
   pase una cosa— y no como interruptor, que es lo que hay a su lado.

   Trapecio con el lado DERECHO recto y sólo el izquierdo inclinado, misma
   familia que #btnDownloadPDF. El contorno no puede ser un `border`: un border
   no sigue a un clip-path y desaparecería justo en el lado inclinado. Se usa la
   maquinaria que ya trae `button` — el fondo del botón hace de borde (--brd) y
   su ::before, 1px hacia dentro, pone el relleno (--fill), los dos recortados
   con la misma forma (--o / --i). */
.vt-btn {
  --tile-h: 24px;
  --tile-slant: calc(var(--tile-h) * tan(15deg));
  --o: polygon(var(--tile-slant) 0, 100% 0, 100% 100%, 0 100%);
  --i: var(--o);
  --brd: var(--line);
  --fill: var(--field);
  width: auto; height: var(--tile-h); flex: 0 0 auto;
  padding: 0 12px 0 18px;   /* más aire del lado inclinado, para que el texto no lo toque */
  font-size: 0.5625rem; letter-spacing: 1px; text-transform: uppercase;
  color: var(--muted);
  transition: color .15s, background .15s;
}
/* Rótulo oscuro, no naranja: --fill sale translúcido (--field ya lo es, y
   color-mix conserva el alfa), así que en hover se ve a través suyo el --brd
   naranja del propio botón — un rótulo naranja encima quedaba ilegible. */
.vt-btn:hover:not(:disabled) {
  --brd: var(--cyan);
  --fill: color-mix(in srgb, var(--cyan) 12%, var(--field));
  color: var(--bg); filter: none;
}
.vt-btn:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
.vt-btn[hidden] { display: none; }
/* "Fijar polos" armado: cambia lo que hace un clic normal sobre el visor, así
   que se marca fuerte — no es un estado que convenga pasar por alto. */
.vt-btn.is-on {
  --brd: var(--cyan); --fill: var(--cyan);
  color: var(--bg); box-shadow: var(--glow);
}
.vt-btn.is-on:hover:not(:disabled) {
  --brd: var(--cyan); --fill: var(--cyan); color: var(--bg);
}

/* Franja de ayudas, debajo de los toggles/slides.
   Flujo de texto corrido, NO flex: como flex, cada ayuda era un ítem propio y
   al no entrar en el ancho bajaba entera a la línea siguiente — en móvil eso
   daba tres líneas, una por ayuda. En flujo normal envuelve por donde toque,
   como cualquier párrafo, y las tres se leen como una sola frase separada por
   puntos.
   El panel de sólidos es un overlay ENCIMA de #viewer (ver .solid-panel), así
   que con el panel abierto tapaba el arranque de este texto: se le reserva ese
   mismo ancho, con la misma cuenta de --panel-open-t que mueve el panel. */
.viewer-hints {
  display: block; text-align: center;
  padding-left: calc(var(--panel-collapsed) + var(--panel-open-t, 1) * (var(--solid-panel-w) - var(--panel-collapsed)));
  margin-top: 8px; padding-top: 8px;
  border-top: 1px solid var(--line-soft);
  font-family: var(--mono); font-size: 0.5625rem; letter-spacing: .3px;
  line-height: 1.7;
  color: var(--muted);
}
/* Con el panel abierto el texto empieza donde empieza el visor visible: en el
   hueco angosto que queda, centrarlo lo dejaba flotando lejos del borde. */
main[data-panel-open="true"] .viewer-hints { text-align: left; }
/* El separador es del CSS y no del HTML: así no hay que meterlo en las cadenas
   traducidas, y el primero nunca lo lleva. */
.viewer-hints span + span::before {
  content: "∙ "; color: var(--cyan); font-weight: 700;
}
.viewer-hints b { color: var(--muted); font-weight: 600; }
/* Los tres hints son casi todos atajos de teclado (doble clic, Ctrl/Cmd+clic):
   en táctil —móvil y tablet— no se pueden usar, así que el texto no informa,
   confunde. `pointer: coarse` y no un ancho de pantalla: un tablet ancho sigue
   siendo táctil, mismo criterio que ya usa el resto de la hoja (ver
   .panel-resizer). */
@media (pointer: coarse) {
  .viewer-hints { display: none; }
}

/* Confirmaciones pasajeras, aparte de #viewerHint (ver index.html): mismo
   sitio y mismo aire, pero sin la caja de error — no interrumpe, informa. */
#viewerToast {
  position: absolute; left: 50%; bottom: 56px; transform: translateX(-50%);
  margin: 0; z-index: 6; max-width: 80%; text-align: center;
  padding: 7px 14px;
  background: rgba(4, 9, 17, 0.92); border: 1px solid var(--cyan);
  color: var(--cyan); font-size: 0.5625rem; letter-spacing: 1px;
  text-transform: uppercase; pointer-events: none;
}
#viewerToast[hidden] { display: none; }

@media (max-width: 640px) {
  .viewer-panel__inner { padding: 18px 10px 7px; }
  .vt-row { gap: 6px 10px; }
  .vt-sw__lbl { font-size: 0.5625rem; }
  .viewer-hints { font-size: 0.53125rem; }
}

/* ─── Backdrop + modal ───────────────────────────── */
/* Mismo tratamiento para las CUATRO overlays (desarrollo, acerca de,
   contacto, calidad): el resto de la página queda desenfocado y oscurecido
   detrás de cualquier modal, no sólo el del desarrollo. #modalTierOverlay
   faltaba acá (sólo tenía su propio z-index más abajo, sin posición ni fondo
   ni backdrop-filter): sin caja, no tenía con qué bloquear el clic ni
   desenfocar nada — el modal de desarrollo se seguía viendo nítido y
   usable DETRÁS del de calidad. */
#modalOverlay, #modalAboutOverlay, #modalContactOverlay, #modalTierOverlay {
  position: fixed; inset: 0; z-index: 20;
  background: rgba(2, 5, 11, 0.55);
  backdrop-filter: blur(10px) saturate(.85);
  -webkit-backdrop-filter: blur(10px) saturate(.85);
}
#modalOverlay[hidden], #modalAboutOverlay[hidden], #modalContactOverlay[hidden],
#modalTierOverlay[hidden], .modal[hidden] { display: none; }

.modal {
  position: fixed; left: 50%; top: 50%; z-index: 21;
  transform: translate(-50%, -50%);
  width: min(1100px, 95vw); max-height: 92vh;
  display: flex; flex-direction: column;
  background: var(--panel);
  border: 1px solid var(--line);
  box-shadow: 0 30px 80px rgba(0,0,0,.6), inset 0 0 40px rgba(220,136,62,.04);
  backdrop-filter: blur(20px) saturate(1.2);
  -webkit-backdrop-filter: blur(20px) saturate(1.2);
}
.modal-inner { display: flex; flex-direction: column; min-height: 0; }

.modal-header {
  flex: 0 0 auto;
  /* position:relative es el ancla de la X de cerrar (position:absolute, ver
     .modal-close más abajo) — así el botón queda SIEMPRE centrado
     verticalmente contra el borde derecho, sin importar cuánto crezca la
     cabecera (título en dos líneas, chips de datos del desarrollo). Top
     fijo en vez de 50%+transform se descuadraría apenas la cabecera cambie
     de alto — por eso el centrado es real, no un número calibrado a mano.
     Todo modal con .modal-header hereda esto solo: desarrollo, calidad y
     contacto. */
  position: relative;
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px;
  padding: 10px 44px 8px 16px; border-bottom: 1px solid var(--line);
}
.modal-header h2 {
  margin: 0; font-family: var(--display); font-weight: 700;
  font-size: 0.75rem; letter-spacing: 2px; text-transform: uppercase; color: var(--cyan);
  display: flex; align-items: center; gap: 8px;
}
.modal-title-logo { height: 2.6rem; width: auto; display: block; }
/* La X de cerrar de los CUATRO modales. Va por clase y no por id: con una
   lista de ids, el modal siguiente que se agregue nace con el aspecto de botón
   normal (borde biselado, relleno) hasta que alguien se acuerde de sumarlo —
   que es exactamente lo que le pasó al de calidad. */
.modal-close {
  width: 26px; height: 26px; padding: 0; flex: 0 0 auto;
  background: transparent; clip-path: none; color: var(--muted); font-size: 0.875rem;
}
.modal-close::before { display: none; }
.modal-close:hover, .modal-close:active { color: #fff; filter: none; }
/* Los tres modales con cabecera de título (desarrollo, calidad, contacto)
   centran su X contra el borde derecho; "Acerca de" es distinto — ahí el
   botón va dentro de la franja de controles SOBRE LA FOTO de portada
   (.modal-hero__controls), y ahí lo posiciona el flex del contenedor. */
#btnCloseModal, #btnCloseTier, #btnCloseContact {
  position: absolute; top: 50%; right: 10px; transform: translateY(-50%);
}
/* El press genérico (button:active { transform: translateY(1px) }) pisaría
   ese translateY(-50%) — se cancela acá, no se combina: sólo para estos
   tres, que son los únicos que centran por transform. */
#btnCloseModal:active, #btnCloseTier:active, #btnCloseContact:active {
  transform: translateY(-50%);
}

.net-hint {
  margin: 0; padding: 6px 16px;
  border-bottom: 1px dashed var(--line-soft);
  font-family: var(--mono); font-size: 0.5625rem; letter-spacing: .3px;
  color: var(--muted); text-align: center;
}
.net-hint b { color: var(--muted); font-weight: 600; }

.modal-body {
  flex: 1 1 auto; min-height: 0; overflow: auto;
  padding: 14px 16px;
  display: flex; align-items: center; justify-content: center;
  background: repeating-conic-gradient(#0a1220 0% 25%, #070d18 0% 50%) 50% / 18px 18px;
}
#netCanvas {
  max-width: 100%; max-height: 68vh;
  background: #fff;
  box-shadow: 0 10px 40px rgba(0,0,0,.6);
  cursor: default;
}

.modal-footer {
  flex: 0 0 auto;
  display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap;
  padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
  border-top: 1px solid var(--line);
}
/* En escritorio el botón queda al final de la misma fila que los campos
   (flex:0 auto, sin crecer, así que cae naturalmente a la derecha). En
   móvil/tablet los campos ya ocupan toda la fila y el botón se envuelve
   solo en la suya — ahí, sin justify-content, caería pegado a la
   izquierda. */
@media (max-width: 900px) {
  .modal-footer { justify-content: flex-end; }
}
.modal-footer .field { display: flex; flex-direction: column; gap: 4px; flex: 1 1 150px; }
.modal-footer .field > span {
  font-size: 0.5625rem; letter-spacing: 1.5px; text-transform: uppercase; color: var(--muted);
}
.modal-footer button { width: auto; padding: 8px 16px; flex: 0 0 auto; }
/* Mismo naranja del botón de PDF en la cabecera (.output-tile.primary): acá
   es el único botón, así que no hace falta --brd/--fill, se pisa directo. */
#btnDownloadPDF { --brd: #c0732f; --fill: #c0732f; color: #0f1826; }
#btnDownloadPDF:hover:not(:disabled) { --fill: #c0732f; color: #0f1826; filter: brightness(1.12); }
/* Trapezoidal, misma familia que los botones de descarga de la cabecera
   (.output-tile) y que #btnOpenContact — acá va contra el borde DERECHO del
   modal, así que ese lado queda recto y sólo el izquierdo se inclina. */
#btnDownloadPDF {
  --tile-h: 34px;
  --tile-slant: calc(var(--tile-h) * tan(15deg));
  height: var(--tile-h);
  padding: 0 16px 0 22px;
  clip-path: polygon(var(--tile-slant) 0, 100% 0, 100% 100%, 0 100%);
}

/* ─── Modales "Acerca de" y "Contacto" ─────────────── */
/* Más angostos que el del desarrollo (min(1100px,95vw)): no hace falta tanto
   ancho para un párrafo de texto y un formulario de tres campos. */
.modal--about, .modal--contact { width: min(480px, 92vw); max-height: 88vh; }
.modal--about .modal-body, .modal--contact .modal-body {
  display: block; background: none; padding: 16px 18px 18px;
}

/* El modal "Acerca de" no lleva <h2> ni cabecera de texto: la imagen og,
   pegada al borde superior y del mismo ancho que el modal, hace de título.
   El toggle de idioma queda arriba a la izquierda y la X de cerrar arriba a
   la derecha, los dos superpuestos sobre la imagen. */
.modal-hero { position: relative; flex: 0 0 auto; line-height: 0; }
.modal-hero__img { display: block; width: 100%; height: auto; }
.modal-hero__controls {
  position: absolute; top: 10px; z-index: 1;
  display: flex; align-items: center; gap: 10px;
  padding: 5px 8px;
}
.modal-hero__controls--left { left: 10px; }
.modal-hero__controls--right { right: 10px; }

/* Las X de cerrar son de 26px, chicas para el dedo. Acá SÍ conviene un área
   invisible más grande que el botón (al revés que en .vt-view): están solas en
   su esquina, sin vecinos con los que solaparse, y agrandar el botón correría
   la X de la esquina o taparía más foto en el modal. */
@media (pointer: coarse) {
  .modal-close { position: relative; }
  #btnCloseModal, #btnCloseTier, #btnCloseContact { position: absolute; }
  .modal-close::after {
    content: ''; position: absolute;
    top: 50%; left: 50%; transform: translate(-50%, -50%);
    width: 44px; height: 44px;
  }
}
/* Sin fondo detrás (tapaba la imagen): sombra en vez de placa sólida para
   que sigan leyéndose sobre una foto de brillo variable. */
.modal-hero__controls .vt-wire__lbl, .modal-hero__controls .vt-sw__lbl {
  color: #d6e0ec; text-shadow: 0 1px 4px rgba(0,0,0,.85);
}
/* La X, aparte: naranja de acento en reposo (así se distingue del texto
   blanquecino del toggle de idioma, que va al lado) y blanca en hover/click —
   mismo criterio de los otros tres modales, sólo que ahí el fondo propio del
   botón hacía ese trabajo; acá, sin fondo, lo hace el color solo. */
.modal-hero__controls #btnCloseAbout {
  color: var(--cyan); text-shadow: 0 1px 4px rgba(0,0,0,.85);
}
.modal-hero__controls #btnCloseAbout:hover, .modal-hero__controls #btnCloseAbout:active {
  color: #fff;
}
.modal-hero__controls .vt-sw__track { box-shadow: 0 1px 4px rgba(0,0,0,.85); }

.about-lead { margin: 0 0 14px; font-size: 0.6875rem; line-height: 1.6; color: var(--fg); }
.about-lead strong { color: var(--cyan); font-weight: 700; }

.about-group { margin-bottom: 14px; }
.about-group h3 {
  margin: 0 0 6px; font-family: var(--display); font-weight: 700;
  font-size: 0.5625rem; letter-spacing: 1.5px; text-transform: uppercase; color: var(--cyan-2);
}
.about-steps { margin: 0; padding-left: 2rem; font-size: 0.625rem; line-height: 1.7; color: var(--muted); }
.about-steps li b { color: var(--fg); }
.about-credits { margin: 0; padding: 0; list-style: none; font-size: 0.625rem; line-height: 1.7; color: var(--muted); }
.about-credits b { color: var(--fg); font-weight: 600; }

.about-author { margin: 0; font-size: 0.625rem; line-height: 1.7; color: var(--muted); }

/* Atajos de teclado. Dos columnas en cuanto hay ancho: son ocho filas cortas
   y en una sola columna estiran el modal más de lo que aporta. */
.kbd-list {
  margin: 0; padding: 0; list-style: none;
  display: grid; grid-template-columns: 1fr; gap: 4px 16px;
  font-size: 0.5625rem; line-height: 1.5; color: var(--muted);
}
@media (min-width: 420px) { .kbd-list { grid-template-columns: 1fr 1fr; } }
.kbd-list li { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.kbd-list span { color: var(--muted); }
kbd {
  font-family: var(--mono); font-size: 0.5rem; font-weight: 700;
  line-height: 1; padding: 3px 5px; white-space: nowrap;
  color: var(--fg); background: var(--field);
  border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 3px;
}

/* Escala global de la interfaz: escribe --ui-scale en :root (ver
   html{font-size} arriba), así que mueve texto e íconos a la vez. */
.ui-scale { display: flex; flex-direction: column; gap: 5px; }
.ui-scale > span {
  font-size: 0.5625rem; letter-spacing: 1.5px; text-transform: uppercase; color: var(--muted);
}
.ui-scale__row { display: flex; align-items: center; gap: 10px; }
.ui-scale__row b {
  color: var(--cyan); font-size: 0.625rem; font-weight: 700;
  font-variant-numeric: tabular-nums; min-width: 42px;
}
#uiScale {
  -webkit-appearance: none; appearance: none; flex: 1 1 auto; height: 4px;
  background: var(--field); border: 1px solid var(--line); outline: none;
  cursor: pointer; border-radius: 999px;
}
#uiScale::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; width: 13px; height: 13px;
  background: var(--cyan); border: 0; border-radius: 50%;
  box-shadow: var(--glow); cursor: pointer;
}
#uiScale::-moz-range-thumb {
  width: 13px; height: 13px; border: 0; border-radius: 50%;
  background: var(--cyan); box-shadow: var(--glow); cursor: pointer;
}
#uiScale::-moz-range-track {
  height: 4px; background: var(--field); border: 1px solid var(--line); border-radius: 999px;
}
.about-author b { color: var(--fg); font-weight: 600; }
.about-footer {
  display: flex; gap: 8px; margin-top: 4px; padding-top: 14px; border-top: 1px solid var(--line);
}
.about-footer button { width: auto; padding: 8px 16px; flex: 0 0 auto; }
/* Trapezoidal, misma familia que los botones de descarga de la cabecera
   (.output-tile, ver ahí el porqué de la cuenta): acá va contra el borde
   IZQUIERDO del modal, así que ese lado queda recto y sólo el derecho se
   inclina — lo opuesto de Descargar PDF, que va contra el derecho. */
#btnOpenContact {
  --tile-h: 34px;
  --tile-slant: calc(var(--tile-h) * tan(15deg));
  height: var(--tile-h);
  padding: 0 22px 0 16px;
  clip-path: polygon(0 0, calc(100% - var(--tile-slant)) 0, 100% 100%, 0 100%);
}
.about-footer .perigami-link {
  margin-left: auto; display: flex; align-items: center; width: auto; flex: 0 0 auto;
  background: none; clip-path: none; padding: 0;
}
.about-footer .perigami-link::before { display: none; }
.about-footer .perigami-link img { height: 1.9em; width: auto; opacity: .7; transition: opacity .15s; }
.about-footer .perigami-link:hover img { opacity: 1; }

/* ─── Modal de calidad de la descarga ──────────────── */
/* Por encima del modal del desarrollo (z-index 21/20): en el caso del PDF éste
   se abre ENCIMA de aquél, no en su lugar. */
#modalTierOverlay { z-index: 30; }
.modal--tier { z-index: 31; width: min(620px, 94vw); }
.modal--tier .modal-body { display: block; background: none; padding: 16px 18px 18px; }

/* Más aire lateral en móvil. Va acá, DESPUÉS de las tres reglas de ancho de
   arriba: mismo peso de selector, así que el orden es lo único que decide, y
   puesto antes (junto al resto de lo móvil) no aplicaba nada. */
@media (max-width: 640px) {
  .modal { width: min(1100px, 88vw); }
  .modal--about, .modal--contact { width: min(480px, 88vw); }
  .modal--tier { width: min(620px, 88vw); }
}

.tier-cards { display: grid; gap: 12px; grid-template-columns: 1fr; }
@media (min-width: 480px) { .tier-cards { grid-template-columns: 1fr 1fr; } }

.tier-card {
  display: flex; flex-direction: column; gap: 8px;
  padding: 14px; border: 1px solid var(--line); background: var(--field);
}
/* La de pago se distingue por el borde, no por un fondo llamativo: la gratuita
   tiene que seguir leyéndose como una opción de verdad, no como el descarte. */
.tier-card--plus { border-color: var(--cyan); }
.tier-card h3 {
  margin: 0; font-family: var(--display); font-weight: 700;
  font-size: 0.5625rem; letter-spacing: 1.5px; text-transform: uppercase;
  color: var(--cyan-2);
}
.tier-card--plus h3 { color: var(--cyan); }
.tier-card__price {
  margin: 0; font-size: 0.75rem; font-weight: 700; color: var(--fg);
  letter-spacing: .5px;
}
.tier-specs {
  margin: 0; padding: 0; list-style: none; flex: 1 1 auto;
  font-size: 0.5625rem; line-height: 1.8; color: var(--muted);
}
/* Viñeta propia en vez de la del navegador: alineada con el texto y del color
   de acento, como el resto de las listas de la app. */
.tier-specs li::before { content: '· '; color: var(--cyan); font-weight: 700; }
/* Mismo trapecio que #btnDownloadPDF: van contra el borde derecho de su
   tarjeta, así que ese lado queda recto y sólo el izquierdo se inclina. A lo
   ancho de toda la tarjeta —como estaban— no eran de la familia de ningún otro
   botón de la app.
   :not(.tier-link) — sin esto, esta regla (clase+elemento, más específica que
   la SOLA clase .tier-link de más abajo) le ganaba el ancho/padding/clip-path
   al link "Restaurar acceso", que vive DENTRO de esta misma tarjeta y quedaba
   recortado en un trapecio ajeno en vez de la línea de texto que debía ser —
   la causa real de que se viera pegado a la izquierda. */
.tier-card button:not(.tier-link) {
  --tile-h: 34px;
  --tile-slant: calc(var(--tile-h) * tan(15deg));
  width: auto; height: var(--tile-h);
  align-self: flex-end;
  margin-top: 4px;
  padding: 0 16px 0 22px;
  clip-path: polygon(var(--tile-slant) 0, 100% 0, 100% 100%, 0 100%);
}
.tier-card__note {
  margin: 0; font-size: 0.5rem; line-height: 1.5; color: var(--muted);
  text-align: right;
}

/* ─── Elegir qué comprar, dentro de la tarjeta Plus ────────────────── */
.tier-choose {
  display: flex; flex-direction: column; gap: 4px; margin: 2px 0 4px;
  font-size: 0.625rem; color: var(--fg);
}
.tier-choose label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.tier-choose input { accent-color: var(--cyan); }

/* "¿Ya compraste?" en texto plano arriba, "Restaurar acceso" pegado justo
   debajo — son la misma frase partida en dos líneas, no dos párrafos, así
   que van con line-height apretado y sin espacio entre medio. Mismo tamaño/
   color que .tier-card__note para la pregunta.
   flex column + align-items:flex-end, no text-align: un hijo <button> block
   con width:auto en flujo normal DEBERÍA estirarse al ancho del padre y
   dejar que text-align lo alinee por dentro — pero seguía pegado a la
   izquierda (dos intentos con text-align, ninguno se vio en pantalla). Con
   flex, el ancho de cada línea es el de su propio contenido (no el del
   padre) y align-items lo empuja al borde derecho directamente: no depende
   de que un ancho "auto" se resuelva de una forma particular. */
.tier-restaurar-prompt {
  display: flex; flex-direction: column; align-items: flex-end;
  margin: 6px 0 0; font-size: 0.5rem; line-height: 1.2; color: var(--muted);
}

/* El link: ni botón trapezoidal ni tecla, para que no compita con el CTA
   primario de arriba. clip-path/::before en none porque sigue siendo un
   <button> — sin apagarlos, el bisel genérico de fondo (ver el ::before de
   la regla base de button, más arriba) se veía como un borde fantasma al
   pasar el mouse, aunque el propio botón no tuviera background ni border. */
.tier-link {
  display: block; width: auto;
  background: none; border: none; padding: 0; margin: 0;
  font-family: inherit; font-size: 0.5625rem; line-height: 1.2; text-align: right;
  text-transform: none; letter-spacing: normal;
  color: var(--cyan-2); text-decoration: underline; cursor: pointer;
  clip-path: none;
}
.tier-link::before { content: none; }
/* button.tier-link (no .tier-link a secas): button:hover:not(:disabled) de la
   regla base (clase+pseudoclase+pseudoclase+elemento) es MÁS específico que
   .tier-link:hover sola — ganaba ella, dejando el texto oscuro (var(--bg)) en
   vez de blanco. Con el elemento sumado acá empatan en especificidad, y esta
   regla gana por venir después en el archivo. */
button.tier-link:hover, button.tier-link:active, button.tier-link:focus-visible {
  color: #fff; filter: none;
}

/* ─── "¿Ya compraste?" y pantalla de resultado ─────────────────────── */
/* Comparten el body del modal con .tier-cards; tierModal.js muestra sólo uno
   de los tres a la vez (ver comentario en index.html). */
.tier-restaurar { max-width: 360px; margin: 0 auto; }
.tier-restaurar > p:first-child { margin: 0 0 12px; font-size: 0.625rem; color: var(--muted); line-height: 1.5; }
/* Mismo trapecio que los de .tier-card (borde derecho recto): el bisel
   genérico de esquinas cortadas (--o/--i, ver la regla base de button más
   arriba) ya no se usa en ningún botón de la app. .tier-restaurar no es flex
   (a diferencia de .tier-card), así que el auto-margin es lo que alinea a la
   derecha un botón de ancho propio en vez de align-self. */
.tier-restaurar button {
  width: auto; margin: 4px 0 0 auto;
  --tile-h: 34px;
  --tile-slant: calc(var(--tile-h) * tan(15deg));
  height: var(--tile-h);
  padding: 0 16px 0 22px;
  clip-path: polygon(var(--tile-slant) 0, 100% 0, 100% 100%, 0 100%);
}

.tier-resultado { text-align: center; padding: 6px 4px 2px; max-width: 360px; margin: 0 auto; }
.tier-resultado__icono { width: 44px; height: 44px; margin: 0 auto 12px; }
.tier-resultado__icono--ok { color: var(--cyan); }
.tier-resultado__icono--error { color: #c0732f; }
.tier-resultado__icono--espera { color: var(--muted); animation: tier-girar 1.4s linear infinite; }
@keyframes tier-girar { to { transform: rotate(360deg); } }
.tier-resultado h3 {
  margin: 0 0 8px; font-family: var(--display); font-weight: 700;
  font-size: 0.6875rem; letter-spacing: 1px; text-transform: uppercase; color: var(--fg);
}
.tier-resultado p { margin: 0 0 14px; font-size: 0.625rem; color: var(--muted); line-height: 1.5; }
.tier-resultado button { width: auto; }

/* ─── Formulario de contacto ────────────────────────── */
.form-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.form-field span {
  font-size: 0.5625rem; letter-spacing: 1.5px; text-transform: uppercase; color: var(--muted);
}
.form-field input, .form-field textarea {
  width: 100%; padding: 7px 9px;
  border: 1px solid var(--line); background: var(--field); color: var(--fg);
  font-family: var(--mono); font-size: 0.6875rem; letter-spacing: .3px;
  outline: none; border-radius: 0; resize: vertical;
  transition: border-color .15s, box-shadow .15s;
}
.form-field textarea { min-height: 80px; }
.form-field input:focus, .form-field textarea:focus { border-color: var(--cyan); box-shadow: var(--glow); }
/* Honeypot: existe en el DOM (los bots de formulario lo rellenan igual),
   pero invisible y fuera del orden de tabulación para una persona real. */
.hp-field { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

.captcha-row {
  display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
  font-size: 0.625rem; color: var(--muted);
}
.captcha-row input {
  width: 64px; padding: 7px 9px;
  border: 1px solid var(--line); background: var(--field); color: var(--fg);
  font-family: var(--mono); font-size: 0.6875rem; text-align: center;
}
.captcha-row input:focus { border-color: var(--cyan); box-shadow: var(--glow); outline: none; }

.contact-status {
  margin: 0 0 10px; padding: 8px 10px; font-size: 0.625rem; line-height: 1.5;
  border: 1px solid var(--line);
}
/* --amber ya no es un tono cálido (ver :root) así que no sirve para marcar
   error — se usa el marrón claro de la paleta (mismo tono que --cyan-2): el
   más oscuro (#a45f22) no llegaba al contraste AA (3.6:1) sobre --bg. */
.contact-status.error { color: #c0732f; border-color: rgba(192,115,47,.4); }
.contact-status.ok { color: var(--cyan); border-color: var(--line); }
.contact-status[hidden] { display: none; }

/* Mismo trapecio que #btnOpenContact ("Contactar" en el modal de info): borde
   izquierdo recto y sólo el derecho inclinado. Los dos son el botón que cierra
   su formulario/pantalla, así que se leen como el mismo gesto. */
#btnSendContact {
  --tile-h: 34px;
  --tile-slant: calc(var(--tile-h) * tan(15deg));
  width: auto; height: var(--tile-h);
  padding: 0 22px 0 16px;
  clip-path: polygon(0 0, calc(100% - var(--tile-slant)) 0, 100% 100%, 0 100%);
}

/* Datos del desarrollo en la cabecera del modal (caras, piezas, uniones,
   tamaño…): misma familia trapezoidal que los botones de descarga — un chip
   por detalle, casi pegados entre sí (ver el margen negativo más abajo),
   alternando el sentido igual que PDF/OBJ/PNG, con los dos extremos del grupo
   rectos en vez de en punta. */
.net-info { display: flex; flex-wrap: wrap; row-gap: 5px; }
.net-info__chip {
  --tile-h: 24px;
  --tile-slant: calc(var(--tile-h) * tan(15deg));
  height: var(--tile-h);
  display: flex; align-items: center;
  padding: 0 14px;
  background: var(--line); color: var(--muted);
  font-size: 0.5625rem; letter-spacing: 1px; white-space: nowrap;
  clip-path: polygon(
    var(--tile-slant) 0, calc(100% - var(--tile-slant)) 0,
    100% 100%, 0 100%
  );
}
.net-info__chip b { color: var(--cyan); font-weight: 700; }
/* Pares: espejado en Y (base ancha arriba) — mismo criterio que #btnOBJ. */
.net-info__chip:nth-child(even) {
  clip-path: polygon(
    0 0, 100% 0,
    calc(100% - var(--tile-slant)) 100%, var(--tile-slant) 100%
  );
}
/* Casi pegados: el margen negativo compensa el ancho que el propio bisel le
   resta al hueco entre dos chips vecinos — ver la nota de la misma regla en
   .output-tile. */
.net-info__chip + .net-info__chip { margin-left: calc(3px - var(--tile-slant)); }
/* El primero siempre es impar (base ancha abajo): recto el lado que da
   hacia afuera del grupo (izquierda). */
.net-info__chip:first-child {
  clip-path: polygon(0 0, calc(100% - var(--tile-slant)) 0, 100% 100%, 0 100%);
}
/* El último puede caer en un chip par o impar según cuántos detalles haya
   esta vez (con hojas/sin pestaña, a veces son 6, a veces 4) — cubre los
   dos casos, siempre recto del lado derecho. */
.net-info__chip:last-child:nth-child(odd) {
  clip-path: polygon(var(--tile-slant) 0, 100% 0, 100% 100%, 0 100%);
}
.net-info__chip:last-child:nth-child(even) {
  clip-path: polygon(0 0, 100% 0, 100% 100%, var(--tile-slant) 100%);
}
/* Un solo detalle (caso raro, pero posible): los dos bordes rectos. */
.net-info__chip:first-child:last-child { clip-path: none; }

/* ─── Progreso ───────────────────────────────────── */
.progress { display: flex; align-items: center; gap: 8px; }
.progress[hidden] { display: none; }
.progress-track {
  flex: 1 1 auto; height: 4px; min-width: 60px;
  background: var(--field); border: 1px solid var(--line);
  overflow: hidden;
}
.progress-fill {
  height: 100%; width: 0%;
  background: repeating-linear-gradient(
    135deg,
    var(--cyan) 0 6px,
    color-mix(in srgb, var(--cyan) 55%, transparent) 6px 12px
  );
  background-size: 200% 100%;
  box-shadow: var(--glow);
  transition: width .12s linear;
  animation: progress-stripes 900ms linear infinite;
}
/* Franja "de corte" en diagonal en vez de una barra lisa — el mismo motivo
   de líneas rectas del papel recortándose, no un genérico spinner. */
@keyframes progress-stripes { from { background-position: 0 0; } to { background-position: -24px 0; } }
@media (prefers-reduced-motion: reduce) { .progress-fill { animation: none; } }
.progress-pct {
  font-size: 0.5625rem; letter-spacing: 1px; color: var(--cyan);
  font-variant-numeric: tabular-nums; min-width: 32px; text-align: right;
}
.progress-stage {
  font-size: 0.5625rem; letter-spacing: 1.5px; text-transform: uppercase;
  color: var(--muted); white-space: nowrap;
}

/* Panel de progreso sobre la vista del desarrollo */
.net-overlay {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  display: flex; align-items: center; gap: 14px;
  width: min(420px, 80%); padding: 14px 18px;
  background: rgba(4, 9, 17, 0.92);
  border: 1px solid var(--line);
  box-shadow: 0 20px 60px rgba(0,0,0,.7);
}
.net-overlay[hidden] { display: none; }
.modal-body { position: relative; }

/* Ícono temático del cálculo: un sólido girando, en vez del típico spinner
   genérico — "buscando el mejor desarrollo" prueba muchas orientaciones
   de corte, igual que este poliedro dando vueltas. */
.net-overlay__icon {
  flex: 0 0 auto; color: var(--cyan);
  animation: net-overlay-spin 2.4s linear infinite;
  filter: drop-shadow(0 0 6px rgba(220, 136, 62, .45));
}
@keyframes net-overlay-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .net-overlay__icon { animation: none; } }
.net-overlay__text {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; gap: 6px;
}

/* ─── Preloaders ─────────────────────────────────── */
/* Arranque de la app: pantalla completa, HTML/CSS puro (visible desde el
   primer paint, sin esperar a ningún módulo JS) — app.js lo saca del medio
   (ver hideAppLoader) apenas el visor 3D queda listo o se sabe que algo
   falló. Mismo fondo que el resto del sitio (--bg) para que no se note el
   salto cuando se retira y aparece la cabecera/el visor de verdad debajo. */
.app-loader {
  position: fixed; inset: 0; z-index: 100;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 14px;
  background: var(--bg);
  opacity: 1; transition: opacity .25s ease;
}
.app-loader.is-hiding { opacity: 0; pointer-events: none; }
.app-loader__icon {
  color: var(--cyan);
  animation: net-overlay-spin 2.4s linear infinite;
  filter: drop-shadow(0 0 8px rgba(220, 136, 62, .5));
}
@media (prefers-reduced-motion: reduce) { .app-loader__icon { animation: none; } }
.app-loader__text {
  font-family: var(--display); font-size: 0.625rem; font-weight: 700;
  letter-spacing: 2px; text-transform: uppercase; color: var(--muted);
}

/* Mapeo de imagen: mismo overlay que .net-overlay pero acotado al visor 3D
   (no a un modal de 420px) y sin barra de progreso — el mapeo es un solo
   paso síncrono, no algo que se pueda medir en tramos como el cálculo del
   desarrollo. */
.viewer-loading { width: auto; max-width: 90%; padding: 10px 16px; gap: 10px; }
.viewer-loading .net-overlay__icon { width: 26px; height: 26px; }

button:focus-visible, select:focus-visible {
  outline: 2px solid var(--cyan); outline-offset: 2px;
}

/* ─── Movimiento reducido ────────────────────────── */
/* Quien pidió menos animación en su sistema (mareos, vértigo, sensibilidad
   vestibular) no debería tragarse las transiciones de los paneles ni los
   barridos. No se anula del todo con `animation: none`: dejarlas en un
   instante conserva los eventos de fin de transición que algún día pueda
   estar esperando el JS, sin que se vea movimiento. Las animaciones que
   dispara el propio JS (barrido de bienvenida, ir al desarrollo plano) se
   saltan aparte — ver prefersReducedMotion() en app.js. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}
