/* =========================================================================
   NÚCLEO — components.css
   Los componentes que el inventario de Fase 1 encontró REINVENTADOS de forma
   independiente tres veces o más. No es un catálogo de lo que podría hacer
   falta: cada bloque de aquí abajo existe porque el repositorio ya lo tiene
   escrito varias veces con nombres distintos.

   Prefijo `ds-` en todo. Es lo que mantiene el núcleo inerte mientras dura la
   migración: ninguna de estas clases existe hoy en las ~47.000 líneas de CSS
   de app, así que cargar este fichero no puede cambiar una pantalla sin
   migrar. Ver la cabecera de base.css.

   Reglas que se cumplen aquí sin excepción (DESIGN.md §2, §4, §5):
     - Ni un literal de color, tamaño de fuente o espaciado. Todo de tokens.
     - Ninguna sombra salvo en lo que flota de verdad (modal, panel lateral).
     - Ningún estado comunicado solo por color: siempre texto, y icono en los
       críticos.
     - El acento solo en navegación, acción primaria, fila seleccionada, foco
       y logotipo. Aquí eso significa: .ds-btn--primary, .ds-table
       tr[aria-selected] y el anillo de foco. En ningún borde decorativo.
   ========================================================================= */

/* =========================================================================
   1. SUPERFICIES
   Las tarjetas NO llevan sombra (DESIGN.md §1): se separan con borde y
   escalón de fondo. Reinventado en ~18 de los 20 CSS del repo.
   ========================================================================= */

.ds-panel {
  background: var(--n-0);
  border: var(--bd-strong);
  border-radius: var(--r-md);
}

.ds-panel__head {
  display: flex;
  align-items: center;
  gap: var(--s-5);
  padding: var(--s-5) var(--s-6);
  border-bottom: var(--bd);
}

.ds-panel__title {
  font-size: var(--fs-md);
  font-weight: 600;
  line-height: var(--lh-tight);
}

.ds-panel__body { padding: var(--s-6); }

/* El cuerpo de un panel que contiene una tabla no lleva relleno: la tabla
   llega hasta el borde y son sus celdas las que respiran. */
.ds-panel__body--flush { padding: 0; }

.ds-panel__foot {
  display: flex;
  align-items: center;
  gap: var(--s-5);
  padding: var(--s-5) var(--s-6);
  border-top: var(--bd);
  background: var(--n-25);
  border-radius: 0 0 var(--r-md) var(--r-md);
}

/* Cabecera de pantalla: título, contexto y la acción primaria a la derecha. */
.ds-pagehead {
  display: flex;
  align-items: flex-start;
  gap: var(--s-6);
  padding-bottom: var(--s-6);
  border-bottom: var(--bd);
}

.ds-pagehead__title {
  font-size: var(--fs-xl);
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: -0.01em;
}

.ds-pagehead__sub {
  margin-top: var(--s-2);
  font-size: var(--fs-xs);
  color: var(--n-500);
}

.ds-pagehead__actions {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--s-4);
}

/* ⚠️ EL ARMAZÓN CEDE SU CABECERA A LA PANTALLA (2026-08-17, medido en la app del
   doctor).

   Un armazón de app instalada pinta su propia `ds-pagehead` —el nombre de dónde
   estás, que en un móvil no lo da ninguna barra superior—, y las pantallas ya
   migradas traen la suya dentro del contenido. Cuando coinciden pasan dos cosas,
   las dos medidas: **dos `<h1>` en el documento** —un lector de pantalla anuncia
   dos títulos de página— y **48 px de chrome muerto encima del pliegue**, que a
   390 × 664 es un cuarto de lo que se ve sin hacer scroll y es justo el alto que
   se está peleando pantalla por pantalla.

   Manda la de la PANTALLA, porque es la que dice algo: la del armazón es el
   nombre de la app. Y se resuelve por la ESTRUCTURA y no pantalla por pantalla:
   así vale para la que se añada el año que viene sin que nadie se acuerde de
   esto — que es exactamente cómo aparecieron las dos primeras.

   `display: none` y no `visibility`: hay que quitarlo del árbol de
   accesibilidad, no solo de la vista, o el lector sigue leyendo los dos.

   Acotado a los armazones que declaran barra inferior (`--con-nav-inferior`):
   son los que pintan cabecera propia. El `> ` es imprescindible — la cabecera de
   la pantalla vive dentro de `main`, y sin él se retirarían las dos. */
.ds-app--con-nav-inferior:has(main .ds-pagehead) > .ds-pagehead { display: none; }

/* =========================================================================
   2. BOTONES
   Tres pesos y no más: primaria (una por pantalla), normal y fantasma.
   ========================================================================= */

.ds-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-3);
  height: var(--ctl-h);
  padding: 0 var(--s-5);
  border: var(--bd-strong);
  border-radius: var(--r-sm);
  background: var(--n-0);
  color: var(--n-800);
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease);
}

.ds-btn:hover { background: var(--n-50); }
.ds-btn:active { background: var(--n-100); }

/* ⚠️ UN BOTÓN QUE ES UN `<a>` PIERDE SU COLOR AL PASARLE EL RATÓN, y hay que
   reafirmarlo aquí (2026-08-17). Es el mismo choque que la cabecera de
   `base.css` describe entre el reset y la decisión, solo que con la hoja
   ANTIGUA y por el otro lado:

     - `static/css/base.css` declara `a:hover { color: var(--accent-600) }`,
       que es (0,1,1) —elemento + pseudo-clase—, y `base.html` la carga DESPUÉS
       de este fichero, a propósito, para que el CSS de las apps sin migrar
       siga ganando durante la migración.
     - Las variantes de botón son (0,1,0) y solo declaran su color en reposo.

   Así que (0,1,1) le ganaba a (0,1,0) y **el texto de CUALQUIER botón-enlace
   se volvía acento al pasar por encima**. En el primario eso es índigo sobre
   índigo: el rótulo desaparece justo en el momento de pulsarlo. Lo destapó
   «Empezar sin presupuesto» de Primera Visita, pero no era de esa pantalla —
   le pasaba a los tres pesos de botón en las 30 sub-apps, y al `--danger`, que
   además perdía lo único que dice que es destructivo.

   `.ds-btn:hover` es (0,2,0) y le gana a (0,1,1) sin `!important` y sin tocar
   el orden de carga, que es una decisión deliberada de `base.html`. El
   subrayado viene del mismo sitio (`.ds-app :where(a):hover`) y se quita por
   lo mismo: un botón no se subraya.

   Se retira el día que `css/base.css` deje de cargarse, o sea cuando no quede
   una sola pantalla sin migrar. */
.ds-btn:hover,
.ds-btn:active { color: var(--n-800); text-decoration: none; }

.ds-btn--primary:hover,
.ds-btn--primary:active { color: var(--n-0); }

.ds-btn--ghost:hover,
.ds-btn--ghost:active { color: var(--n-600); }

.ds-btn--danger:hover,
.ds-btn--danger:active { color: var(--risk); }

.ds-btn:disabled:hover,
.ds-btn[aria-disabled="true"]:hover { color: var(--n-400); }

.ds-btn:disabled,
.ds-btn[aria-disabled="true"] {
  color: var(--n-400);
  background: var(--n-50);
  cursor: not-allowed;
}

/* La acción primaria. Es uno de los cinco sitios donde el acento aparece,
   así que como mucho una por pantalla: dos botones de acento es no haber
   decidido cuál es la siguiente acción. */
.ds-btn--primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--n-0);
}

.ds-btn--primary:hover  { background: var(--accent-600); border-color: var(--accent-600); }
.ds-btn--primary:active { background: var(--accent-600); }

.ds-btn--ghost {
  background: transparent;
  border-color: transparent;
  color: var(--n-600);
}

.ds-btn--ghost:hover { background: var(--n-100); }

/* Destructiva. Va en texto rojo sobre fondo neutro, no en rojo macizo: un
   botón rojo lleno compite con los badges de riesgo de la propia tabla. */
.ds-btn--danger {
  color: var(--risk);
  border-color: var(--n-200);
}

.ds-btn--danger:hover {
  background: var(--risk-bg);
  border-color: var(--risk);
}

/* Solo icono. Lleva aria-label obligatorio; sin él es un botón mudo. */
.ds-btn--icon {
  width: var(--ctl-h);
  padding: 0;
}

.ds-btn--lg {
  height: calc(var(--ctl-h) + var(--s-4));
  padding: 0 var(--s-7);
  font-size: var(--fs-base);
}

/* Grupo de botones pegados (vistas, rangos de fecha, densidad). */
.ds-btngroup { display: inline-flex; }

.ds-btngroup .ds-btn { border-radius: 0; }
.ds-btngroup .ds-btn:first-child { border-radius: var(--r-sm) 0 0 var(--r-sm); }
.ds-btngroup .ds-btn:last-child  { border-radius: 0 var(--r-sm) var(--r-sm) 0; }
.ds-btngroup .ds-btn + .ds-btn   { margin-left: -1px; }
.ds-btngroup .ds-btn[aria-pressed="true"] {
  background: var(--n-100);
  color: var(--n-900);
  font-weight: 600;
}

/* =========================================================================
   3. CAMPOS
   La etiqueta es una micro-etiqueta condensada: es la firma del sistema y
   además comprime el formulario, que es lo que necesita una pantalla densa.
   ========================================================================= */

.ds-field {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

.ds-field__label {
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-500);
}

.ds-field__label--req::after {
  content: " *";
  color: var(--risk);
}

.ds-input,
.ds-select,
.ds-textarea {
  width: 100%;
  min-height: var(--ctl-h);
  padding: var(--s-3) var(--s-4);
  border: var(--bd-strong);
  border-radius: var(--r-sm);
  background: var(--n-0);
  color: var(--n-800);
  font-size: var(--fs-base);
  line-height: var(--lh-base);
}

.ds-textarea { min-height: calc(var(--ctl-h) * 3); }

/* Tres renglones es el mínimo razonable para un texto libre, y de más para un
   campo que casi siempre tiene DOS líneas —los teléfonos de un contacto: un
   móvil y a veces un fijo—. El `rows="2"` del marcado no basta, porque el
   `min-height` de arriba lo pisa. Importa donde el alto es el recurso escaso:
   en el editor de la Libreta (§26) esos 28px son una fila de fichas menos en
   la lista que hay debajo, en cada pantalla y todo el rato. */
.ds-textarea--corta { min-height: calc(var(--ctl-h) * 2); }

/* Un control dentro de una BARRA (`.ds-cluster`, `.ds-pagehead__actions`) y no
   dentro de un formulario en columna. El `width: 100%` de arriba es correcto en
   un `.ds-field` —los campos de un formulario se alinean— y equivocado aquí: la
   barra envuelve, así que la etiqueta, el campo y el botón caen en tres
   renglones y una sola pregunta ocupa el alto de tres. Pasó en la Bandeja de
   Recepción (2026-08-17): el selector de ventana comía el espacio de la lista
   que hay debajo, en una pantalla grande.

   No se arregla quitando el 100 % de `.ds-select`: en un formulario en columna
   haría que cada campo midiese lo que mide su opción más larga, y la columna
   dejaría de estar alineada. Son dos sitios y dos anchos. */
.ds-input--auto,
.ds-select--auto { width: auto; }

.ds-input::placeholder,
.ds-textarea::placeholder { color: var(--n-400); }

.ds-input:disabled,
.ds-select:disabled,
.ds-textarea:disabled {
  background: var(--n-50);
  color: var(--n-500);
}

/* Solo lectura: un campo que no se puede escribir tiene que PARECER que no se
   puede escribir. Sin esto es idéntico a uno editable, así que quien lo tiene
   delante hace clic, teclea y no pasa nada — la pantalla no falla, miente.
   No es `:disabled`: el texto sigue leyéndose entero y se puede seleccionar y
   copiar, que es justo para lo que se usa (un campo que rellena la aplicación).
   De ahí que conserve el color del texto y solo pierda el fondo blanco. */
.ds-input[readonly],
.ds-textarea[readonly] {
  background: var(--n-50);
  cursor: default;
}

/* Importes y cantidades: alineados a la derecha y tabulares también dentro
   del input, o el número salta al pasar de edición a celda. */
.ds-input--num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

.ds-field__help {
  font-size: var(--fs-xs);
  color: var(--n-500);
}

/* Error: borde + texto. El borde rojo solo sería color (DESIGN.md §5). */
.ds-field--error .ds-input,
.ds-field--error .ds-select,
.ds-field--error .ds-textarea { border-color: var(--risk); }

.ds-field__error {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  font-size: var(--fs-xs);
  color: var(--risk);
}

.ds-check {
  display: inline-flex;
  align-items: center;
  gap: var(--s-4);
  font-size: var(--fs-base);
  cursor: pointer;
}

.ds-check input { accent-color: var(--accent); }

/* LA CASILLA SUELTA (2026-08-17).
   `.ds-check` viste la pareja etiqueta + casilla, y eso cubre el marcado que
   escribe una plantilla. No cubre el que escribe **Django**: un
   `forms.CheckboxInput` rinde el `<input>` y nada más, así que la clase va en la
   casilla y no en un envoltorio que no existe. Sin esto, la regla de arriba no
   la alcanza nunca y el `input` sale con el azul por defecto del navegador — el
   mismo azul que DESIGN.md §1 prohíbe por escrito.

   Sube al núcleo porque ya se estaba usando: `apps/common/incidencias/forms.py`
   pone `class="ds-checkbox"` desde su migración y **ninguna hoja la declaraba**.
   Es el modo de fallo que este sistema tiene peor: una clase que nadie declara
   se ve casi bien —la casilla nativa funciona— y no falla nunca, así que nada
   avisa. Al pasar los 270 controles de `contabilidad/forms.py` y
   `stock/forms.py` se encontró el hueco, y arreglarlo ES la tarea: el contrato
   (§2) dice que si falta un componente se lleva al núcleo, no se escribe en la
   app.

   El objetivo táctil sigue el mismo criterio que el resto: 44px con puntero
   grueso, aunque el modo compacto esté activo (§6). Se hace con `outline` y no
   con caja para no mover la línea de base del renglón donde vive la casilla. */
.ds-checkbox {
  accent-color: var(--accent);
  width: var(--s-6);
  height: var(--s-6);
  margin: 0;
  flex: none;
  cursor: pointer;
}

@media (pointer: coarse) {
  .ds-app .ds-checkbox {
    width: var(--s-7);
    height: var(--s-7);
  }
}

/* Sección de formulario. Un formulario largo no es una lista de campos: son
   grupos con nombre, y sin ellos «Fecha prevista» y «Cubetas devueltas» pesan
   lo mismo. La rejilla reparte los campos en columnas que se adaptan al ancho,
   así que uno de veinte campos no obliga a bajar veinte veces.

   Sube al núcleo porque la primera app migrada ya lo necesitaba y lo resolvió
   con `<h3 style="...">` incrustado en la plantilla (stock/producto_form.html):
   eso es la señal de que faltaba el componente, no de que sobrara la sección. */
.ds-fieldset {
  border: 0;
  margin: 0;
  padding: 0;
  min-width: 0;
}

.ds-fieldset + .ds-fieldset { margin-top: var(--s-9); }

.ds-fieldset__legend {
  display: block;
  width: 100%;
  padding: 0 0 var(--s-4);
  margin-bottom: var(--s-6);
  border-bottom: var(--bd-strong);
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-500);
}

.ds-formrow {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: var(--s-6);
}

.ds-formrow + .ds-formrow { margin-top: var(--s-6); }

/* Campo que ocupa la fila entera: notas, direcciones, cualquier texto largo. */
.ds-formrow__full { grid-column: 1 / -1; }

/* Pie de formulario. La acción primaria a la izquierda —donde termina de leer
   quien acaba de rellenar— y la destructiva separada por el empujón, para que
   «Eliminar» no quede pegado a «Guardar». */
.ds-formfoot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-5);
  margin-top: var(--s-9);
  padding-top: var(--s-6);
  border-top: var(--bd-strong);
}

/* =========================================================================
   4. BADGE DE ESTADO
   SIEMPRE texto; icono en los críticos. Un badge que solo es color no lo
   lee ni un daltónico ni una impresión en blanco y negro, y esto es
   software clínico (DESIGN.md §5). El icono va con aria-hidden: lo que se
   lee en voz alta es el texto.
   ========================================================================= */

.ds-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-1) var(--s-4);
  border-radius: var(--r-pill);
  background: var(--idle-bg);
  color: var(--idle);
  font-size: var(--fs-xs);
  font-weight: 600;
  line-height: var(--lh-tight);
  white-space: nowrap;
}

.ds-badge__i { font-size: var(--fs-micro); }

.ds-badge--ok   { background: var(--ok-bg);   color: var(--ok); }
.ds-badge--warn { background: var(--warn-bg); color: var(--warn); }
.ds-badge--risk { background: var(--risk-bg); color: var(--risk); }
.ds-badge--info { background: var(--info-bg); color: var(--info); }
.ds-badge--idle { background: var(--idle-bg); color: var(--idle); }

/* Punto de estado para listas muy densas, donde el badge no cabe. Lleva
   siempre el texto al lado; el punto es refuerzo, no sustituto. */
.ds-dot {
  display: inline-block;
  width: var(--s-4);
  height: var(--s-4);
  border-radius: var(--r-pill);
  background: currentColor;
  flex: none;
}

/* =========================================================================
   5. TABLA
   El componente más usado de Lydent. La referencia real del repositorio es
   la columna numérica de Contabilidad (.importe-cell / .tes-num), no la de
   Analytics: es la única familia que ya alinea a la derecha, no envuelve y
   usa numeración tabular.
   ========================================================================= */

.ds-table {
  width: 100%;
  font-size: var(--cell-fs);
}

/* Cabecera: micro-etiqueta condensada sobre regla de un píxel. La firma. */
.ds-table thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  padding: var(--s-3) var(--cell-pad-x);
  background: var(--n-0);
  border-bottom: var(--bd-strong);
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-500);
  text-align: left;
  white-space: nowrap;
}

.ds-table tbody td {
  height: var(--row-h);
  padding: var(--cell-pad-y) var(--cell-pad-x);
  border-bottom: var(--bd);
  vertical-align: middle;
}

.ds-table tbody tr:hover td { background: var(--n-50); }

/* Fila seleccionada: uno de los cinco sitios del acento. */
.ds-table tbody tr[aria-selected="true"] td { background: var(--accent-bg); }

/* LA FILA DE LA QUE SE SALIÓ, al volver del detalle o de la edición
   (`ds_url_ficha … ancla=`, `nucleo.js`). El querystring devuelve la lista con
   sus filtros y su página; esto devuelve el RENGLÓN, que en una tabla de 40
   filas es la mitad de «donde estaba».

   Se apaga sola a los pocos segundos y no es un estado: si se quedara fija se
   confundiría con la selección de arriba, que sí lo es y significa otra cosa.
   El destello no comunica nada por sí mismo —solo dice «mira aquí»—, así que
   no incumple la regla de no informar solo con color. */
.ds-table tbody tr:target td {
  animation: ds-fila-vuelta 2.4s var(--ease) 1;
}

@keyframes ds-fila-vuelta {
  0%, 55% { background: var(--accent-bg); }
  100% { background: transparent; }
}

@media (prefers-reduced-motion: reduce) {
  .ds-table tbody tr:target td { animation: none; background: var(--accent-bg); }
}

/* Zebra SOLO a partir de 8 columnas (DESIGN.md §4). Por debajo, la regla de
   un píxel ya guía el ojo y el zebreado solo mete ruido. */
.ds-table--zebra tbody tr:nth-child(even) td { background: var(--n-25); }
.ds-table--zebra tbody tr:hover td { background: var(--n-50); }

/* Columna numérica. Derecha, sin envolver, tabular, mismos decimales en toda
   la columna y el símbolo de moneda SOLO en la cabecera. */
.ds-table th.ds-num,
.ds-table td.ds-num {
  text-align: right;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* Signo y color. El signo «−» es lo que comunica; el color solo refuerza. */
/* SOLO PARA MAGNITUDES CON SIGNO, y esto es una regla, no un consejo. El «-»
   —o el «+» que la plantilla escribe delante— es el segundo canal, y el color
   solo lo subraya. Usarlas por UMBRAL («win-rate por debajo de 50, en rojo»)
   deja el color como único canal, que es lo que §5 prohíbe: medido, --ok y
   --risk se separan solo ΔE 7,7 en deuteranopia, así que ese rojo y ese verde
   son el mismo color para bastante gente. Si lo que se quiere marcar es un
   umbral, es un badge con su palabra, no una cifra teñida.
   (Analytics, 2026-08-06: se retiraron 15 usos por umbral introducidos en su
   propia migración.) */
.ds-table td.ds-num--neg { color: var(--risk); }
.ds-table td.ds-num--pos { color: var(--ok); }

/* El cero no es un dato: se apaga para que las cifras reales destaquen. */
.ds-table .ds-num--zero { color: var(--n-300); }

/* Columna de totales y fila de resultado. */
.ds-table .ds-col--total {
  border-left: var(--bd-strong);
  font-weight: 600;
  background: var(--n-25);
}

.ds-table tfoot td,
.ds-table tr.ds-row--total td {
  height: var(--row-h);
  padding: var(--cell-pad-y) var(--cell-pad-x);
  border-top: var(--bd-strong);
  background: var(--n-50);
  font-weight: 600;
}

/* Primera columna fija en tablas anchas. Se activa a mano con --fija: no
   todas las tablas anchas la quieren, y fijarla sin querer parte el scroll. */
.ds-table--fija tbody th:first-child,
.ds-table--fija tbody td:first-child {
  position: sticky;
  left: 0;
  z-index: 1;
  background: var(--n-0);
}

.ds-table--fija tbody tr:hover td:first-child { background: var(--n-50); }

.ds-table--fija thead th:first-child {
  position: sticky;
  left: 0;
  z-index: 3;
}

/* Orden: el indicador se ve SIEMPRE en la columna ordenada, no solo al pasar
   el ratón — en tableta no hay ratón (DESIGN.md §4 y §6). */
.ds-table th[aria-sort] { cursor: pointer; }
.ds-table th[aria-sort]:hover { color: var(--n-700); }

.ds-table th[aria-sort]::after {
  content: "";
  display: inline-block;
  width: 0;
  height: 0;
  margin-left: var(--s-3);
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
}

.ds-table th[aria-sort="ascending"]::after  { border-bottom: 5px solid var(--n-600); }
.ds-table th[aria-sort="descending"]::after { border-top: 5px solid var(--n-600); }
.ds-table th[aria-sort="none"]::after       { border-bottom: 5px solid var(--n-300); }

/* Celda de acciones por fila: se reserva el ancho siempre, para que la fila
   no dé un salto lateral al aparecer los botones. */
.ds-table td.ds-cell--acciones {
  width: 1%;
  white-space: nowrap;
  text-align: right;
}

/* Y los botones de esa celda NO ENVUELVEN. Lo destapó el Club al mirar sus
   listas en pantalla: con dos o tres acciones por fila metidas en un
   `.ds-cluster` —que es `flex-wrap: wrap`— el `width: 1%` de arriba las
   apilaba en vertical y cada fila pasaba a ocupar tres renglones; la tabla
   dejaba de ser una tabla. Envolver es lo correcto en una barra de acciones de
   pantalla, no dentro de una celda que ya se dimensiona por su contenido. */
.ds-table td.ds-cell--acciones .ds-cluster { flex-wrap: nowrap; }

/* Cuando la celda de acciones lleva un formulario con campo —«canjear» pide el
   teléfono, «reenviar» el número nuevo—, ese campo NO cabe: la fila se sale de
   la pantalla y la acción principal queda fuera, que es el mismo defecto que
   §5 del arquetipo A ya corrigió en la barra de selección. Un campo dentro de
   la celda de acciones es la señal de que eso pide un modal. */

/* ------------------------------------------------------------------------
   5.a MÓVIL — LA TABLA SE VUELVE TARJETAS
   DESIGN.md §6 lo prohíbe expresamente: nunca scroll horizontal de tabla
   completa. En móvil se ven 3–4 columnas y el resto se despliega.

   Requisito de marcado: cada <td> lleva `data-label` con el texto de su
   cabecera, y las columnas que no entran en el resumen llevan `ds-col--sec`.

   Vivía en `archetypes/a-tabla.css` §5 y se mudó aquí el 2026-08-06 al migrar
   Analytics. El motivo es de fondo: esto es comportamiento del COMPONENTE
   tabla, no del armazón de una app de listas, y mientras vivió en el
   arquetipo A cualquier app que no fuese de tablas —pero que tuviera
   tablas— se quedaba sin él. Analytics tiene 84, y se abre en el móvil del
   gabinete tanto como en el PC del despacho.
   ------------------------------------------------------------------------ */

@media (max-width: 640px) {
  /* La tabla deja de ser tabla. Hace falta bajar `display:block` también a
     <table>, <tbody> y <tfoot>: con las filas en bloque pero la tabla
     todavía en `display:table`, las filas se encogen al ancho de su
     contenido y las tarjetas salen a media columna. */
  .ds-table--responsive,
  .ds-table--responsive tbody,
  .ds-table--responsive tfoot {
    display: block;
    width: 100%;
  }

  .ds-table--responsive thead { display: none; }

  .ds-table--responsive tbody tr {
    display: block;
    width: 100%;
    padding: var(--s-5) var(--s-6);
    border-bottom: var(--bd-strong);
  }

  .ds-table--responsive tbody td {
    display: flex;
    align-items: baseline;
    gap: var(--s-5);
    width: 100%;
    height: auto;
    padding: var(--s-2) 0;
    border: 0;
  }

  .ds-table--responsive tbody td::before {
    content: attr(data-label);
    flex: none;
    width: 38%;
    font-family: var(--font-cond);
    font-size: var(--fs-micro);
    font-weight: 600;
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    color: var(--n-500);
  }

  /* Casilla y acciones no son datos: no llevan rótulo y no gastan la
     columna de la etiqueta. La casilla encabeza la tarjeta y las acciones
     la cierran por la derecha. */
  .ds-table--responsive tbody td[data-label=""]::before { content: none; }

  /* El `width: 1%` que en escritorio impide que la columna de acciones robe
     ancho a los datos hay que deshacerlo aquí, o el botón se queda pegado a
     la izquierda de una celda de un píxel. */
  .ds-table--responsive tbody td.ds-cell--acciones {
    width: 100%;
    justify-content: flex-end;
    padding-top: var(--s-4);
  }

  /* La fila seleccionada se tiñe entera, no celda a celda. */
  .ds-table--responsive tbody tr[aria-selected="true"] { background: var(--accent-bg); }
  .ds-table--responsive tbody tr[aria-selected="true"] td { background: none; }
  .ds-table--responsive tbody tr:hover td { background: none; }

  .ds-table--responsive tfoot tr { display: block; padding: var(--s-5) var(--s-6); }
  .ds-table--responsive tfoot td { display: flex; justify-content: space-between; }

  /* Columnas secundarias: solo cuando la fila está desplegada. */
  .ds-table--responsive tbody tr:not([data-expanded="true"]) .ds-col--sec {
    display: none;
  }

  /* Y que se note que hay algo debajo. La regla de arriba llevaba desde
     2026-08-06 escondiendo columnas SIN que nada las pudiera revelar —el
     atributo no lo ponía nadie— y sin ninguna señal de que existieran: la
     tabla se veía correcta, solo que le faltaban datos. El disparador está en
     `nucleo.js` y es opt-in con `data-ds-expandible`; esto es su rótulo. */
  /* `:has()` porque una pantalla declara la tabla entera pero no todas sus
     filas esconden algo: sin esto, una fila sin columnas secundarias saldría
     con el cursor y la flecha de «pulsa aquí» y no pasaría nada al pulsar.
     Un rótulo que promete lo que no hay es peor que no ponerlo. */
  .ds-table--responsive[data-ds-expandible] tbody tr:has(.ds-col--sec) {
    cursor: pointer;
  }

  .ds-table--responsive[data-ds-expandible] tbody tr:has(.ds-col--sec)
    td:first-child::after {
    content: "▾";
    margin-left: var(--s-3);
    color: var(--n-400);
  }

  .ds-table--responsive[data-ds-expandible]
    tbody tr[data-expanded="true"]:has(.ds-col--sec) td:first-child::after {
    content: "▴";
  }

  /* El número deja de ir a la derecha: en tarjeta compite con su rótulo. */
  .ds-table--responsive td.ds-num { text-align: left; }
}

/* Una tabla de lectura analítica no siempre puede volverse tarjeta: la de
   detalle mensual son 12 filas × N áreas y en tarjetas ocuparía doce
   pantallas. Ahí la salida es la del propio DESIGN.md §6 —enseñar 3–4
   columnas— con las demás marcadas como secundarias, que es lo que hace
   `.ds-col--sec` fuera del modo tarjeta. Se declara aparte y no dentro del
   bloque de arriba porque aplica a tablas que NO son `--responsive`. */
@media (max-width: 640px) {
  .ds-table:not(.ds-table--responsive) .ds-col--sec { display: none; }
}

/* ------------------------------------------------------------------------
   5.a-bis MÓVIL — LA TARJETA COMPACTA  (`ds-table--compacta`)

   La tarjeta de §5.a gasta UN RENGLÓN POR COLUMNA: con las 3–4 columnas que
   §6 permite, más la celda de acciones, una fila son cuatro o cinco renglones
   y un dedo de alto. Medido el 2026-08-17 en la bandeja del doctor del
   Coordinador (arnés `files/coordinador_mis_tareas/medir_mis_tareas.py`, 390 ×
   664 px, `pointer: coarse`, armazón real): **239 px por tarea y UNA tarea
   visible sin scroll** en la pantalla que es el `start_url` de la app
   instalada. DESIGN.md §1 pide justo lo contrario —Linear, densidad sin
   ruido, «cada píxel se gana ayudando a decidir o a actuar»—.

   Esta variante reparte la tarjeta en DOS LÍNEAS en vez de N:

     línea 1   la identidad de la fila (`ds-cell--titulo`) + la acción
     línea 2   el resto de las columnas visibles, EN LÍNEA

   Con eso la misma fila mide **82 px y caben 5** —cuatro cuando la cola es
   larga y se gana su línea de orden—, sin quitar ni un dato de la pantalla: lo
   que sale de la tarjeta va a `ds-col--sec`, o sea al desplegable que la fila ya
   tenía.

   Es un modificador de `--responsive`, no un sustituto: se declara junto a
   ella y solo cambia el estado PLEGADO. Al desplegar (`data-expanded="true"`)
   la fila vuelve a la tarjeta con rótulos de §5.a, que es donde se leen los
   detalles y donde el rótulo sí hace falta.

   CONTRATO DE MARCADO, y son tres cosas:

     1. `ds-cell--titulo` en la celda que identifica la fila. Su sitio en el
        marcado da igual —el reparto lo hace `order`— salvo si la tabla es
        `data-ds-expandible`: ahí va PRIMERA, porque el «▾» que avisa de que hay
        algo debajo lo pinta §5.a en `td:first-child` y tiene que caer junto al
        nombre.
     2. El rótulo de las demás celdas va EN LÍNEA (ya no es una columna del
        38 %), así que lo que se entiende solo se marca `data-label=""` y se
        ahorra el rótulo. Es el mecanismo que §5.a ya usa para la casilla y las
        acciones: aquí no cambia, solo se usa más. Y cuando el dato NO se
        entiende solo pero el rótulo tampoco cabe —«2 semanas»—, el rótulo se
        pone `ds-sr-only` dentro de la celda: va absoluto, así que el lector de
        pantalla lo oye y la tarjeta no paga un píxel.
     3. Lo que no quepa en dos líneas, `ds-col--sec`. Si hay que elegir, la
        pregunta es «¿esto hace falta para decidir si entro?» y no «¿es un dato
        importante?»: lo segundo lo son todos.

   ⚠️ SOLO SE COMPACTA LA FILA QUE TIENE TÍTULO (`:has(> .ds-cell--titulo)`), y
   por eso el selector lo repite en todas las reglas. En un `tbody` conviven
   filas que no son tarjetas —la de detalle que rellena un AJAX, la de «no hay
   resultados»—, y convertirlas en una línea de datos las rompe. Que la fila
   declare que es una tarjeta es más barato que ir tapando los casos.
   ------------------------------------------------------------------------ */

@media (max-width: 640px) {
  /* La separación entre datos es `--s-4` y no `--s-5` porque la segunda línea va
     JUSTA: a 390 px mide **326 px** y el caso normal de la bandeja del doctor
     pide 293 (tipo «Revisión de estudio» 122 + espera 68 + estado «En proceso»
     92, más las dos separaciones). Con 12 px sobraban 17 y con 8 sobran 33, que
     es lo que hace falta para que un rótulo un poco más largo no se lleve el
     estado a un tercer renglón. */
  .ds-table--compacta tbody tr:not([data-expanded="true"]):has(> .ds-cell--titulo) {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    column-gap: var(--s-4);
    padding: var(--s-4) var(--s-6);
  }

  /* La celda deja de ser un renglón: mide lo que mide su dato. */
  .ds-table--compacta tbody tr:not([data-expanded="true"]):has(> .ds-cell--titulo) > td {
    width: auto;
    padding: 0;
  }

  /* Y su rótulo deja de reservar el 38 % del ancho: va delante del dato, en la
     misma línea. Sigue siendo la micro-etiqueta condensada de la firma
     (DESIGN.md §1); lo único que cambia es que no monta columna. */
  .ds-table--compacta tbody tr:not([data-expanded="true"]):has(> .ds-cell--titulo) > td::before {
    width: auto;
  }

  /* LÍNEA 1 — LA IDENTIDAD. Se lleva el ancho que le deje la acción y nunca
     lleva rótulo: un nombre no necesita que le pongan «NOMBRE» delante.
     ⚠️ `flex-basis: 0` y no `auto`, y no es un detalle: en una línea que
     ENVUELVE, un ítem se baja de renglón cuando no cabe **por su tamaño base**,
     y solo después se reparte lo que falta. Con `auto` el nombre entero era el
     tamaño base, así que el botón de la acción se iba a un renglón propio —«
     Revisar plan» medido a 390 px: la fila pasaba de 78 px a 126—. Con base 0 el
     nombre pide cero, la acción cabe siempre y el nombre se queda todo lo demás.
     Es la misma trampa que `.ds-select[data-orden]` en la hoja del Coordinador. */
  .ds-table--compacta tbody tr:not([data-expanded="true"]):has(> .ds-cell--titulo) > td.ds-cell--titulo {
    flex: 1 1 0;
    min-width: 0;
    font-weight: 600;
  }

  .ds-table--compacta tbody
    tr:not([data-expanded="true"]):has(> .ds-cell--titulo) > td.ds-cell--titulo::before {
    content: none;
  }

  /* Lo que se pulsa en ella MIDE 44 (§6), y no cuesta un píxel de fila: la
     línea ya la fija la acción, que también mide 44. En la bandeja del doctor
     eran 24 enlaces de paciente a 42 px —un defecto anterior que se arregla al
     compactar, porque es el mismo renglón—. `min-height` y no `padding`: el
     valor sale de `--ctl-h`, que en táctil es 44 y en escritorio 28. */
  .ds-table--compacta tbody
    tr:not([data-expanded="true"]):has(> .ds-cell--titulo) > td.ds-cell--titulo a {
    display: inline-flex;
    align-items: center;
    gap: var(--s-3);
    min-height: var(--ctl-h);
  }

  /* La acción cierra la línea 1, con su rótulo puesto: no se convierte en un
     botón de icono, que es lo que obligaría a hacer si estuviera fuera del
     flujo. */
  .ds-table--compacta tbody tr:not([data-expanded="true"]):has(> .ds-cell--titulo) > td.ds-cell--acciones {
    width: auto;
    padding: 0;
  }

  /* EL CORTE DE LÍNEA ES EXPLÍCITO, y por eso hay un pseudo-elemento en medio.
     Un `flex-wrap` parte la línea donde deja de caber, o sea que la línea 2
     empezaría en un sitio distinto por fila según lo largo que sea el nombre.
     Un ítem de base 100 % y alto 0 entre los dos grupos —el título y la acción
     con `order` 0, las demás celdas con 2— parte SIEMPRE en el mismo sitio y
     no pinta nada. */
  .ds-table--compacta tbody tr:not([data-expanded="true"]):has(> .ds-cell--titulo)::after {
    content: "";
    order: 1;
    flex-basis: 100%;
    height: 0;
  }

  /* LÍNEA 2 — lo que hace falta para decidir, en línea y en orden de marcado. */
  .ds-table--compacta tbody tr:not([data-expanded="true"]):has(> .ds-cell--titulo)
    > td:not(.ds-cell--titulo):not(.ds-cell--acciones) {
    order: 2;
  }
}

/* =========================================================================
   5.b CELDA EDITABLE
   Descubierto en el piloto de Stock: su tabla de productos no es una lista,
   es una rejilla que se edita en el sitio (stock mínimo, uds. de pedido…),
   y eso no lo cubría ni `.ds-input` ni la celda normal. Lo reinventan también
   Gestión Taller y Contabilidad con su propio CSS en línea.

   Estrecho, tabular y alineado a la derecha como el resto de la columna
   numérica: si al entrar en edición el número se mueve o cambia de forma, la
   columna deja de escanearse.
   ========================================================================= */

.ds-cellinput {
  width: 100%;
  max-width: 5rem;
  height: calc(var(--row-h) - var(--s-5));
  padding: 0 var(--s-4);
  border: var(--bd-strong);
  border-radius: var(--r-sm);
  background: var(--n-0);
  font-size: var(--cell-fs);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.ds-cellinput:disabled {
  background: var(--n-50);
  color: var(--n-500);
}

/* Confirmación de guardado. Es un acuse transitorio de una acción que el
   usuario acaba de hacer, no un estado de la fila, así que aquí el color sí
   basta: nadie lo lee sin haber pulsado antes. El error NO es transitorio y
   por eso lleva texto al lado (`.ds-field__error`). */
.ds-cellinput--saved { border-color: var(--ok); background: var(--ok-bg); }
.ds-cellinput--error { border-color: var(--risk); background: var(--risk-bg); }

/* =========================================================================
   5.c PESTAÑAS DE SECCIÓN
   Otro hallazgo del piloto, y de los caros: la navegación por secciones está
   reinventada al menos seis veces con nombres distintos —`stock-nav`,
   `config-tabs__tab--active`, `coord-tab--active`, `caja-mes-tab--active`,
   `acceso-tab--active`, `mock-modal__tab--active`—. Dos niveles, porque
   Stock usa dos y no es el único.

   El ítem activo es uno de los cinco sitios donde el acento puede aparecer
   (DESIGN.md §5: «navegación, barra e ítem activo»).
   ========================================================================= */

/* ⚠️ 2026-08-17 — ESTE NIVEL TAMBIÉN ENVUELVE, y por el mismo motivo que el
   nivel 2 (ver el bloque de `.ds-tabs--sub` más abajo, que lo razona entero).

   Era un carril horizontal —`overflow-x: auto` con los ítems a `nowrap`—
   escrito sobre un supuesto que está a diez líneas de aquí: «arriba hay cuatro
   grupos y caben siempre». Medido: de las **22 barras de primer nivel del
   repositorio, 15 pasan de cuatro ítems**, y dos tienen quince. El supuesto era
   falso, y su fallo es mudo — con las barras de scroll superpuestas (el default
   de macOS y de buena parte de Windows 11) no hay NINGUNA señal de que haya
   más: la fila termina cortada y se lee como el final de la lista.

   Lo destapó Recepción, que no es la barra de una app sino la de un PUESTO y
   tiene ocho secciones de tres apps distintas. La última es «Conversaciones»,
   además con `ds-push`, o sea la primera en caerse por el borde: la pantalla
   donde recepción pasa el día quedaba fuera de su propia navegación.

   Envolver no esconde ni un ítem en ninguna resolución y baja sola a una fila
   cuando la barra es corta. `overflow-x: visible` porque envolver hace que no
   haya desbordamiento que gestionar, y dejar el `auto` reintroduciría el carril
   en cuanto un ítem fuera más ancho que la caja. */
.ds-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  padding: 0 var(--s-6);
  border-bottom: var(--bd-strong);
  background: var(--n-0);
  overflow-x: visible;
}

.ds-tabs__item {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-5) var(--s-5);
  border-bottom: var(--bd-w-signal) solid transparent;
  color: var(--n-600);
  font-size: var(--fs-base);
  font-weight: 500;
  white-space: nowrap;
  text-decoration: none;
}

.ds-tabs__item:hover { color: var(--n-900); text-decoration: none; }

.ds-tabs__item[aria-current] {
  color: var(--accent);
  border-bottom-color: var(--accent);
  font-weight: 600;
}

/* Segundo nivel: mismo vocabulario, menos peso. Va sobre fondo escalonado
   para que se lea como dependiente del primero y no como su competencia.

   ⚠️ ESTE NIVEL ENVUELVE, EL PRIMERO NO. Y es la diferencia entre los dos:
   arriba hay cuatro grupos y caben siempre; aquí hay tantos ítems como
   módulos tenga el grupo, y Aplicaciones tiene DIECIOCHO.

   Heredaba el `overflow-x: auto` del nivel 1, o sea que esos dieciocho vivían
   en un carril horizontal que en la mayoría de resoluciones no cabía. Tres
   cosas fallaban a la vez, y ninguna daba error:

   1. Con las barras de scroll superpuestas —el default de macOS y de buena
      parte de Windows 11— NO HAY NINGUNA SEÑAL de que haya más. La fila
      termina cortada a media palabra y se lee como el final de la lista.
   2. El módulo activo puede quedar fuera de la vista al cargar, así que la
      navegación no dice dónde estás, que es su otro trabajo.
   3. Con ratón, un carril horizontal no se desplaza: la rueda mueve la
      página. Hay que arrastrar la barra que además no se ve.

   La intención escrita en `includes/nav_modulos.html` era «preferimos que se
   vea que hay más a que haya que adivinarlo», y envolver la cumple mejor que
   el scroller: no esconde ni un ítem en ninguna resolución, y baja sola a una
   fila cuando el grupo es corto. `overflow-x: visible` porque envolver hace
   que no haya desbordamiento que gestionar, y dejar el `auto` heredado
   reintroduciría el carril en cuanto un ítem fuera más ancho que la caja. */
.ds-tabs--sub {
  flex-wrap: wrap;
  overflow-x: visible;
  gap: var(--s-1);
  row-gap: var(--s-2);
  padding: var(--s-3) var(--s-6);
  background: var(--n-50);
  border-bottom: var(--bd);
}

.ds-tabs--sub .ds-tabs__item {
  padding: var(--s-3) var(--s-4);
  border-bottom: 0;
  border-radius: var(--r-sm);
  font-size: var(--fs-sm);
}

.ds-tabs--sub .ds-tabs__item:hover { background: var(--n-100); }

.ds-tabs--sub .ds-tabs__item[aria-current] {
  background: var(--accent-bg);
  color: var(--accent-600);
}

/* LAS COLAS DE UNA BANDEJA CABEN EN UNA FILA, TAMBIÉN EN UN TELÉFONO
   (2026-08-17, con la bandeja del doctor del Coordinador).

   `--colas` no es «otra barra»: es `--sub` cuando sus ítems son las COLAS de una
   misma pantalla —destinos fijos, contados y conocidos de antemano—, y no los
   módulos de un grupo. Esa diferencia es la que permite lo contrario de lo que
   hace `--sub`: aquí **no se envuelve**. Envolver es correcto cuando no se sabe
   cuántos ítems hay (Aplicaciones tiene dieciocho); con cuatro fijos, envolver
   es gastar tres renglones del teléfono en la fila que menos información lleva
   de la pantalla.

   Medido a 390 × 664 px en el armazón del doctor: la barra de colas ocupaba
   **153 px de 664 en tres renglones** y la primera fila de datos empezaba en
   y=449 — dos tercios de lo que se ve sin scroll gastados en cromo.

   Y NO es un carril horizontal, a propósito. `flex: 1 1 auto` + `min-width: 0`
   reparte el ancho entre los ítems y los deja encogerse, así que **no hay
   desbordamiento que gestionar**: ni barra de scroll invisible que esconda un
   destino (§5.c ya lo pagó dos veces), ni una región con `overflow` que se queda
   el gesto del dedo cuando no desborda —eso costó que la pantalla del doctor no
   se pudiera scrollear (`apps/coordinador.css`)—. Lo único que se recorta es el
   ROTULO, y con puntos suspensivos, que sí se ve.

   El icono se retira porque es decoración (`aria-hidden`) y es lo que más ancho
   come por destino; el CONTADOR se queda, porque es el dato: dice si hay algo
   que hacer. */
@media (max-width: 640px) {
  .ds-tabs--colas {
    flex-wrap: nowrap;
    gap: var(--s-1);
    padding-left: var(--s-3);
    padding-right: var(--s-3);
  }

  .ds-tabs--colas .ds-tabs__item {
    flex: 1 1 auto;
    min-width: 0;
    justify-content: center;
    gap: var(--s-2);
    padding-left: var(--s-2);
    padding-right: var(--s-2);
    font-size: var(--fs-xs);
  }

  .ds-tabs--colas .ds-tabs__item > i { display: none; }

  /* El rótulo es lo único elástico de la fila. `min-width: 0` en el ítem no
     basta: sin esto el texto —`white-space: nowrap` heredado del ítem— se sale
     por encima del destino de al lado en vez de recortarse.
     Va scopeado a `--colas` a propósito: fuera de esta variante los ítems no se
     encogen, así que recortar el rótulo solo podría esconder texto sin motivo. */
  .ds-tabs--colas .ds-tabs__rotulo {
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .ds-tabs--colas .ds-badge {
    flex: none;
    padding-left: var(--s-3);
    padding-right: var(--s-3);
  }
}

/* =========================================================================
   5.d BUSCADOR TRANSVERSAL
   Sale del análisis de navegación de Stock: hay 17 cajas de búsqueda, cada
   una atada a su propia lista, y ninguna que cruce las entidades. Ése es el
   motivo real por el que se acaban teniendo cuatro pestañas del navegador
   abiertas — el usuario está supliendo a mano una vista que no existe.

   Vive en la cabecera de la app, no dentro de una lista, porque su trabajo es
   justamente saltarse la lista. Comercial ya resolvió esto mismo con su
   buscador universal de contactos; esto es el componente que allí faltó.
   ========================================================================= */

.ds-search {
  position: relative;
  flex: 1;
  max-width: 520px;
}

.ds-search__input {
  width: 100%;
  height: var(--ctl-h);
  padding: 0 var(--s-6) 0 var(--s-9);
  border: var(--bd-strong);
  border-radius: var(--r-sm);
  background: var(--n-0);
  font-size: var(--fs-base);
}

.ds-search__icono {
  position: absolute;
  left: var(--s-5);
  top: 50%;
  transform: translateY(-50%);
  color: var(--n-400);
  pointer-events: none;
}

/* Atajo de teclado a la derecha. Se enseña porque un buscador que hay que ir
   a buscar con el ratón no ahorra el viaje que venía a ahorrar. */
.ds-search__atajo {
  position: absolute;
  right: var(--s-4);
  top: 50%;
  transform: translateY(-50%);
  padding: var(--s-1) var(--s-3);
  border: var(--bd);
  border-radius: var(--r-sm);
  background: var(--n-50);
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-micro);
  color: var(--n-500);
  pointer-events: none;
}

.ds-search__panel {
  position: absolute;
  top: calc(100% + var(--s-3));
  left: 0;
  right: 0;
  z-index: 40;
  max-height: 60vh;
  overflow-y: auto;
  background: var(--n-0);
  border: var(--bd-strong);
  border-radius: var(--r-md);
  box-shadow: var(--sh-overlay);
}

/* Los resultados van AGRUPADOS por tipo, con el tipo escrito. Una lista plana
   obligaría a deducir de qué es cada fila, que es el trabajo que el buscador
   viene a quitar. */
.ds-search__grupo {
  padding: var(--s-3) var(--s-6) var(--s-2);
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-500);
  border-top: var(--bd);
}

.ds-search__grupo:first-child { border-top: 0; }

.ds-search__res {
  display: flex;
  align-items: center;
  gap: var(--s-5);
  padding: var(--s-4) var(--s-6);
  text-decoration: none;
  color: var(--n-800);
}

.ds-search__res:hover,
.ds-search__res[aria-selected="true"] {
  background: var(--accent-bg);
  text-decoration: none;
}

.ds-search__res-meta {
  margin-left: auto;
  font-size: var(--fs-xs);
  color: var(--n-500);
  font-variant-numeric: tabular-nums;
}

/* Autocompletado. NO es el buscador transversal: es un campo de formulario o
   de filtro que sugiere mientras se teclea, y lo único que necesita del
   componente de arriba es el panel de resultados —de ahí que solo aporte el
   ancla posicionada y reutilice `.ds-search__panel` y `.ds-search__res`—.

   Se sube al núcleo porque estaba reimplementado en SIETE hojas de app
   (configuracion, datalytics, taller, retenciones, contabilidad,
   recordatorios2, odontohr), cada una con su `.autocomplete-results`. Lo
   destapó la segunda app migrada: al borrar `stock.css` el autocompletado de
   la ficha de producto se quedó sin estilo y nadie lo notó, porque el que lo
   pintaba era el CSS viejo de otra app.

   El resaltado del teclado viaja en `aria-selected` y no en una clase
   `active`: así lo anuncia el lector de pantalla, y el panel ya lo pinta. */
.ds-autocomplete { position: relative; }

.ds-autocomplete > .ds-input { width: 100%; }

/* El botón de vaciar va DENTRO del campo. Fuera se apila debajo del input
   —`.ds-field` es una columna— y deja de leerse como «borrar lo escrito»
   para parecer otra acción del formulario. */
.ds-autocomplete__x {
  position: absolute;
  top: 0;
  right: 0;
  height: var(--ctl-h);
  color: var(--n-400);
}

.ds-autocomplete__x:hover { color: var(--n-700); background: none; }

/* =========================================================================
   5.e BANDEJA DE TRABAJO
   La fila de «esto está pendiente y esto es lo que hay que hacer». Es el
   componente de la pantalla «Hoy» de Stock, y el mismo que reinventan las
   alertas de Comercial, las worklists de Coordinador y la cola de auditoría
   de Recordatorios2 — cada una con su CSS.

   Regla: toda fila termina en una ACCIÓN. Una bandeja que solo informa es un
   listado, y para eso ya está la tabla.
   ========================================================================= */

.ds-tasks {
  display: flex;
  flex-direction: column;
}

.ds-task {
  display: flex;
  align-items: center;
  gap: var(--s-6);
  padding: var(--s-5) var(--s-6);
  border-bottom: var(--bd);
  background: var(--n-0);
}

.ds-task:hover { background: var(--n-25); }

/* Cifra grande a la izquierda: cuántos son. Es lo que decide si esta fila se
   atiende ahora o después, así que va primero y en tabular. */
.ds-task__n {
  flex: none;
  min-width: 3.5rem;
  font-size: var(--fs-lg);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  line-height: var(--lh-tight);
  text-align: right;
}

.ds-task__cuerpo { min-width: 0; flex: 1; }

.ds-task__titulo { font-weight: 600; }

.ds-task__detalle {
  margin-top: var(--s-2);
  font-size: var(--fs-xs);
  color: var(--n-500);
}

.ds-task__accion { margin-left: auto; flex: none; }

/* La urgencia tiñe SOLO la cifra, no la fila. Una bandeja con cuatro filas de
   colores distintos deja de leerse como una lista y pasa a ser un semáforo
   averiado; y el nivel va escrito además en el badge del detalle. */
.ds-task--risk .ds-task__n { color: var(--risk); }
.ds-task--warn .ds-task__n { color: var(--warn); }
.ds-task--ok   .ds-task__n { color: var(--ok); }
.ds-task--idle .ds-task__n { color: var(--n-300); }

@media (max-width: 640px) {
  .ds-task { flex-wrap: wrap; }
  .ds-task__accion { margin-left: calc(3.5rem + var(--s-6)); }
}

/* ------------------------------------------------------------------------
   La bandeja EN REJILLA, y COMPACTA. Para cuando la lista de colas no es la
   pantalla sino su cabecera, y lo que hay debajo es el trabajo.

   Una columna es lo correcto cuando la bandeja ES la pantalla —se recorre de
   arriba abajo y se elige—. Deja de serlo en cuanto hay algo debajo que
   también necesita alto: siete colas apiladas en una pantalla ancha gastan
   media página en una columna de texto de 40 caracteres, y el listado de abajo
   se queda en una franja tan estrecha que ni se puede desplazar. Pasó en la
   Bandeja de Recepción (2026-08-17) con siete carriles y un monitor grande.

   Se pide a propósito con el modificador y no se hace sola: la rejilla cambia
   el orden de lectura —de una columna a filas de dos o tres— y eso solo es
   aceptable donde el orden de la lista es una prioridad blanda, no una
   secuencia. En rejilla cada cola se separa por marco y no por línea: sin
   marco, tres celdas contiguas se leen como una sola fila de tres columnas.

   ⚠️ **La rejilla sola no bastaba, y es el mismo problema medido otra vez**
   (CEO, 2026-08-17, sobre la pantalla ya en rejilla): «seguimos consumiendo
   todo el espacio en cards que podrían ser mucho más pequeños sin perder
   significado y nos quedamos sin espacio para las respuestas por resolver».
   Tres cosas gastaban ese alto, y ninguna era la cifra ni el título:

   1. **el párrafo de tres líneas** que explica de qué va la cola — se lee la
      primera vez y se vuelve a pintar las quinientas siguientes;
   2. **el botón en su propio renglón**, que suma un alto de control entero a
      cada celda;
   3. **celdas de 24rem**, que en un monitor ancho dan tres columnas y por
      tanto tres filas para siete colas.

   Ahora la celda es **una línea**: cifra · título · acción a la derecha.
   Medido con las siete colas reales, celda de 180 px → **60 px**:

   | Ancho del contenedor | Antes | Después | Devuelto |
   |---|---:|---:|---:|
   | 1516 px (monitor grande) | 520 px | 128 px | **392 px** |
   | 1280 px (portátil)       | 520 px | 168 px | **352 px** |
   | 1024 px (tablet)         | 690 px | 217 px | **473 px** |

   Tres cuartas partes del alto, y no se ha quitado ni una cifra ni un título:
   en un portátil es la diferencia entre ver tres filas de trabajo y no ver
   ninguna. La comprobación es reproducible y vive en
   `files/recepcion_bandeja_compacta/comparativa.html`, que enlaza estas
   mismas hojas y pinta las dos versiones una encima de otra.

   El texto **no se pierde**: sigue en el DOM (lo lee un lector de pantalla
   entero) y la plantilla lo repite en el `title` de la celda para el ratón.
   Recortar con `overflow` y no con `display:none` es justo esa diferencia.
   ------------------------------------------------------------------------ */
.ds-tasks--cols {
  display: grid;
  /* 21rem y no 24: la celda ya no sostiene un párrafo, pero sí un título de
     seis palabras y su botón. Estrecharla más mete cinco columnas y parte los
     títulos en dos líneas — se gana ancho y se paga en alto, que es lo que se
     venía a ahorrar. */
  grid-template-columns: repeat(auto-fit, minmax(21rem, 1fr));
  gap: var(--s-4);
}

.ds-tasks--cols .ds-task {
  align-items: center;
  flex-wrap: nowrap;
  gap: var(--s-4);
  padding: var(--s-4) var(--s-5);
  border: var(--bd);
  border-radius: var(--r-md);
}

/* La cifra deja de reservar 3,5rem: aquí no hay una columna de cifras que
   alinear —cada celda es una isla— y ese ancho salía del texto. */
.ds-tasks--cols .ds-task__n {
  min-width: 2.25rem;
  text-align: left;
}

.ds-tasks--cols .ds-task__cuerpo { flex: 1 1 auto; }

/* El párrafo sale de la vista, NO del documento: mismas propiedades que
   `.ds-sr-only` (base.css), así que un lector de pantalla lo sigue leyendo
   entero y el ratón lo tiene en el `title` de la celda.

   Se probó antes recortarlo a un renglón con `text-overflow`. No vale, y se
   ve en cuanto se mira: «Contestaron al recordatorio y el sist…» no informa
   de nada que el título no dijera ya, cuesta 20 px por celda y —al estrechar
   la columna para que quepa— parte en dos líneas los títulos, que son lo
   único que de verdad hay que leer aquí. */
.ds-tasks--cols .ds-task__detalle {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* La acción vuelve a la derecha, en la misma línea. Cuando la celda sostenía
   un párrafo esto le robaba el ancho al texto; sin párrafo, no hay a quién
   robárselo. */
.ds-tasks--cols .ds-task__accion {
  margin-left: var(--s-4);
  margin-top: 0;
  padding-left: 0;
  flex: none;
  flex-basis: auto;
}

@media (max-width: 640px) {
  /* En una sola columna la celda compacta sigue siendo lo correcto: el
     `flex-wrap` de la fila ancha devolvería el botón a su propio renglón, que
     es el alto que se viene a quitar. */
  .ds-tasks--cols .ds-task { flex-wrap: nowrap; }
  .ds-tasks--cols .ds-task__accion { margin-left: var(--s-4); }
}

/* =========================================================================
   6. TARJETA KPI
   Rótulo en micro-etiqueta ENCIMA, cifra grande tabular, variación debajo en
   color semántico y con flecha (DESIGN.md §3-C).
   ========================================================================= */

.ds-kpis {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: var(--s-5);
}

/* El relleno se apretó el 2026-08-07 (de --s-5/--s-6 a --s-4/--s-5) por una
   observación del CEO: «las tarjetas de KPI son demasiado grandes, restan
   espacio para la información realmente útil». Medida antes de tocarlas, la
   tira se llevaba 115 px, o sea el 16 % del pliegue en un portátil, para seis
   cifras.

   Lo que se recorta es CHROME, no información: el relleno y los dos huecos
   internos. La cifra sigue en --fs-2xl donde el contrato la pide (§3-C: en un
   informe la cifra ES la pantalla). Dónde deja de ser hero lo decide el
   arquetipo — ver `.ds-a .ds-kpi` en archetypes/a-tabla.css. */
.ds-kpi {
  padding: var(--s-4) var(--s-5);
  background: var(--n-0);
  border: var(--bd-strong);
  border-radius: var(--r-md);
}

.ds-kpi__label {
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-500);
}

.ds-kpi__value {
  margin-top: var(--s-2);
  font-size: var(--fs-2xl);
  font-weight: 600;
  line-height: var(--lh-tight);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
}

.ds-kpi__unit {
  font-size: var(--fs-md);
  font-weight: 500;
  color: var(--n-500);
  margin-left: var(--s-2);
}

.ds-kpi__delta {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  margin-top: var(--s-2);
  font-size: var(--fs-xs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--n-500);
}

.ds-kpi__delta--up   { color: var(--ok); }
.ds-kpi__delta--down { color: var(--risk); }

/* Hay métricas donde bajar es bueno (no-shows, cartera parada). El sentido
   lo decide la plantilla eligiendo la clase, no el signo del número. */
.ds-kpi__delta--good { color: var(--ok); }
.ds-kpi__delta--bad  { color: var(--risk); }

/* KPI QUE FILTRA. Lo pide el detalle de ejecución de Recordatorios2, donde las
   doce cifras de arriba no informan: cada una acota la tabla de abajo («ver
   solo los fallidos»). Allí estaba resuelto con `<div>` + un `click` colgado,
   o sea inalcanzable con el tabulador y mudo para el lector de pantalla.

   Va como VARIANTE y no como componente aparte porque el KPI no cambia de
   forma al volverse control: cambia de elemento (`<button>`) y gana estado.
   El estado viaja en `aria-pressed`, que es lo que lo anuncia además de
   pintarlo — una clase `activo` solo haría lo segundo. */
.ds-kpi--action {
  text-align: left;
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease);
}

/* Dentro de un <button> los hijos son inline por defecto y las tres líneas del
   KPI se pondrían en la misma. */
.ds-kpi--action > * { display: block; }

.ds-kpi--action:hover { border-color: var(--n-400); }
.ds-kpi--action[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-bg); }

/* Sin nada que filtrar el KPI sigue diciendo su cifra, pero deja de ofrecerse
   como control: un botón que no hace nada al pulsarlo se lee como averiado. */
.ds-kpi--action:disabled { cursor: default; color: inherit; background: var(--n-0); }

/* KPI CON SIGNO SEMÁNTICO (Analytics, 2026-08-06).
   Hay cifras que no son neutras: «cartera parada 32.178 €» o «14 noches de
   ETL fallidas» son un riesgo, no un dato. La app lo resolvía con un borde
   izquierdo de color MÁS un fondo en degradado, y el degradado está prohibido
   (§1). Aquí queda solo el borde, que es el caso escrito de `--bd-w-signal`:
   el grueso que ES señal.

   El color NO es la información —§5 no lo permite—: es énfasis sobre un
   rótulo que ya dice lo que pasa, y las variantes de riesgo y aviso llevan
   además su icono en el rótulo. Si al quitarle el color el KPI deja de
   entenderse, el defecto está en el rótulo. */
.ds-kpi--ok   { border-left: var(--bd-w-signal) solid var(--ok); }
.ds-kpi--warn { border-left: var(--bd-w-signal) solid var(--warn); }
.ds-kpi--risk { border-left: var(--bd-w-signal) solid var(--risk); }
.ds-kpi--info { border-left: var(--bd-w-signal) solid var(--info); }
.ds-kpi--idle { border-left: var(--bd-w-signal) solid var(--n-300); }

.ds-kpi--ok   .ds-kpi__label i { color: var(--ok); }
.ds-kpi--warn .ds-kpi__label i { color: var(--warn); }
.ds-kpi--risk .ds-kpi__label i { color: var(--risk); }
.ds-kpi--info .ds-kpi__label i { color: var(--info); }

/* Un KPI cuyo valor es un NOMBRE, no una magnitud: «Doctor con más carga»,
   «Área líder». Baja de cuerpo y admite partir palabra: a --fs-2xl un nombre
   con dos apellidos no cabe en ninguna columna de la tira, y sin
   `overflow-wrap` se sale de la tarjeta en vez de envolver. La cifra
   numérica no necesita ninguna de las dos cosas. */
.ds-kpi__value--texto {
  font-size: var(--fs-lg);
  overflow-wrap: anywhere;
}

/* Un paso más para el valor que además es LARGO. Lo aplica `nucleo.js` por
   longitud del texto, no la plantilla: el caso que lo pidió es el nombre
   comercial de la clínica —«Clínica Carmona - Odontología y Medicina
   Estética»—, que a `--fs-2xl` ocupaba cinco renglones y empujaba a los otros
   tres KPIs de la tira. También suelta el interlineado: a este cuerpo el texto
   se lee como texto y `--lh-tight` lo apelmaza. */
.ds-kpi__value--largo {
  font-size: var(--fs-md);
  line-height: var(--lh-base);
  letter-spacing: 0;
}

/* KPI QUE ES UN ENLACE. La variante de arriba nació para un `<button>` que
   filtra la tabla de su misma pantalla; el Club destapó el otro caso, igual de
   común: la cifra lleva a OTRA lista ya filtrada («1 tarjeta sin registrar en
   Vevi» → esa lista). Como enlace heredaba el color de enlace, así que la cifra
   salía en acento — y §5 no se lo permite: el acento no va en los datos, y aquí
   además compite con los badges de la tabla de al lado. La tarjeta entera es el
   destino, así que el subrayado tampoco aporta. */
a.ds-kpi { color: inherit; text-decoration: none; }
/* El `color: inherit` se reafirma en `:hover` por lo mismo que en `.ds-btn`
   (ver el bloque largo de §2): `a:hover` de la hoja antigua es (0,1,1), se
   carga después y le ganaba a `a.ds-kpi` — así que la cifra se ponía en acento
   justo al apuntarla, que es lo que §5 no permite. */
a.ds-kpi:hover { border-color: var(--n-400); text-decoration: none; color: inherit; }

/* =========================================================================
   6.b BARRA DE PROGRESO
   «Cuánto de esto está hecho». La reinventan el cuadre del asistente de
   albaranes de Gestión Taller (`cuadre-bar`), el avance de importación y las
   barras de ocupación de Analytics, todas con su propio track+fill.

   El relleno va en NEUTRO OSCURO y no en acento: el acento tiene cinco sitios
   (DESIGN.md §5) y una barra de datos no es ninguno — si lo fuera, en una
   pantalla con cuatro barras el acento dejaría de señalar la acción primaria.
   Los modificadores semánticos existen para cuando el avance ES un veredicto
   (conciliado / descuadrado), y entonces la cifra va escrita al lado: la barra
   sola sería estado comunicado por color.
   ========================================================================= */

.ds-progress {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

.ds-progress__track {
  height: var(--s-4);
  border-radius: var(--r-pill);
  background: var(--n-100);
  overflow: hidden;
}

.ds-progress__fill {
  height: 100%;
  background: var(--n-600);
  transition: width var(--dur-base) var(--ease);
}

.ds-progress--ok   .ds-progress__fill { background: var(--ok); }
.ds-progress--warn .ds-progress__fill { background: var(--warn); }
.ds-progress--risk .ds-progress__fill { background: var(--risk); }

.ds-progress__text {
  font-size: var(--fs-xs);
  color: var(--n-500);
  font-variant-numeric: tabular-nums;
}

/* =========================================================================
   7. BARRA DE FILTROS
   Fija, nunca hace scroll (arquetipo A). Colapsable en pantallas estrechas.
   ========================================================================= */

.ds-filters {
  position: sticky;
  top: 0;
  z-index: 4;
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: var(--s-5);
  padding: var(--s-5) var(--s-6);
  background: var(--n-0);
  border-bottom: var(--bd-strong);
}

/* Un campo de filtro NO se dimensiona por su opción más larga. Lo destapó la
   lista de trabajos de Gestión Taller, que tiene nueve filtros: el <select> de
   doctores se estiraba hasta «Dra. Juana María Carmona García», la fila se iba
   de 1.280px y lo que quedaba fuera de pantalla era el botón de acción
   primaria —o sea que la pantalla perdía su acción, no solo un filtro—.
   `flex: 0 1 180px` los iguala y los deja encoger, así que la barra envuelve
   en vez de desbordarse. */
.ds-filters__field { flex: 0 1 180px; min-width: 140px; }

/* EL BUSCADOR DE LA BARRA NO ES UN FILTRO DE 180px. El tope de arriba existe
   contra un `<select>` que se estira hasta su opción más larga; un campo donde
   se TECLEA tiene el problema contrario: a 180px el marcador de posición sale
   cortado a media palabra («…o código de pacien») y quien llega a la pantalla
   no llega a leer qué se espera que escriba. Medido en la entrada de Primera
   Visita, 2026-08-17.

   Crece pero no manda: `1 1 320px` deja que se estire con la barra y el tope
   impide que se coma el hueco de la nota y de la acción, que es lo que pasaba
   antes de que existiera el `flex-basis` de arriba. */
.ds-filters__field--ancho { flex: 1 1 320px; max-width: 30rem; }

.ds-filters__field > .ds-input,
.ds-filters__field > .ds-select { width: 100%; }

/* Un filtro a veces tiene que explicarse (por qué 45 días y no 7), y ese texto
   NO cabe dentro del campo: la columna mide 180px, así que un párrafo de dos
   renglones se parte en seis y estira la barra entera —que es fija y le come
   ese alto a la tabla, justo lo que la barra no debe hacer—, dejando además
   vacío todo el ancho a su derecha. La nota sale del campo y ocupa ese hueco.
   El tope en `ch` es medida de lectura, no de caja: sin él la nota se estira a
   toda la fila y se lee como documentación, no como pie de un filtro. */
.ds-filters__nota {
  flex: 1 1 260px;
  max-width: 64ch;
  margin: 0;
  font-size: var(--fs-xs);
  line-height: 1.45;
  color: var(--n-500);
}

.ds-filters__actions {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--s-4);
}

/* Filtro aplicado. Se ve el filtro puesto sin desplegar la barra, que es lo
   que evita el «no salen datos» de una tabla filtrada y olvidada. */
.ds-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-2) var(--s-4);
  border: var(--bd-strong);
  border-radius: var(--r-pill);
  background: var(--n-50);
  font-size: var(--fs-xs);
}

.ds-chip__x {
  display: inline-flex;
  border: 0;
  background: none;
  padding: 0;
  color: var(--n-500);
  cursor: pointer;
}

.ds-chip__x:hover { color: var(--n-800); }

/* El botón de desplegar solo existe donde la barra no cabe. */
.ds-filters__toggle { display: none; }

@media (max-width: 640px) {
  .ds-filters { align-items: stretch; flex-direction: column; }
  .ds-filters__toggle { display: inline-flex; }
  .ds-filters[data-collapsed="true"] .ds-filters__field { display: none; }

  /* Y el `flex-basis: 180px` de arriba DEJA DE VALER, porque al girar el eje
     principal esos 180px pasan a ser ALTO. Cada filtro pedía entonces 180px de
     altura para una etiqueta y un `<select>` de 36px, así que la barra se leía
     como un formulario con tres dedos de aire entre campos —el defecto que se
     reportó en el dashboard de Comercial el 2026-08-07—. `0 0 auto` los
     devuelve a su altura de contenido; el ancho ya lo da `align-items:
     stretch`. */
  .ds-filters__field { flex: 0 0 auto; }

  /* Mismo motivo para la nota: sus 260px de base serían alto. */
  .ds-filters__nota { flex: 0 0 auto; }
}

/* =========================================================================
   8. ESTADO VACÍO
   Dos variantes, porque son dos mensajes distintos con dos acciones
   distintas (DESIGN.md §4): no hay nada TODAVÍA (crear) frente a el filtro
   no devuelve nada (quitar el filtro). Confundirlas manda al usuario a
   crear un registro que ya existe.
   ========================================================================= */

.ds-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-5);
  padding: var(--s-11) var(--s-6);
  text-align: center;
}

.ds-empty__icon {
  font-size: var(--fs-xl);
  color: var(--n-300);
}

.ds-empty__title {
  font-size: var(--fs-md);
  font-weight: 600;
}

.ds-empty__text {
  max-width: 46ch;
  font-size: var(--fs-base);
  color: var(--n-500);
}

/* =========================================================================
   9. MODAL
   Lo único, junto al panel lateral, que puede llevar sombra: es lo único
   que de verdad flota sobre el documento.
   ========================================================================= */

/* z-index 1100 y no 100: el modal flota sobre el DOCUMENTO, pero también sobre
   el armazón de la app, que todavía es el heredado. Medido en el portal real:
   `navigation.css` pone el menú lateral en 300, su velo en 250 y los menús
   desplegables de la action-bar en 1000, así que a 100 el modal salía DEBAJO
   del menú abierto. Baja a la altura del resto del sistema el día que el
   armazón se migre; hasta entonces esto es un hecho medido, no una holgura. */
.ds-modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-6);
  background: var(--scrim);
}

.ds-modal {
  width: 100%;
  max-width: 560px;
  max-height: 88vh;
  display: flex;
  flex-direction: column;
  background: var(--n-0);
  border-radius: var(--r-lg);
  box-shadow: var(--sh-overlay);
}

.ds-modal--lg { max-width: 880px; }

.ds-modal__head {
  display: flex;
  align-items: center;
  gap: var(--s-5);
  padding: var(--s-6);
  border-bottom: var(--bd);
}

.ds-modal__title {
  font-size: var(--fs-lg);
  font-weight: 600;
  line-height: var(--lh-tight);
}

.ds-modal__x { margin-left: auto; }

.ds-modal__body {
  padding: var(--s-6);
  overflow-y: auto;
}

/* `flex-wrap: wrap` como en `.ds-formfoot`: un pie de modal no siempre son dos
   botones. Cuando además lleva estado a la izquierda —el modal de WhatsApp
   dice si la sesión está vinculada— en móvil no caben en una línea, y sin
   envolver el contenido se sale de la caja en vez de bajar. */
.ds-modal__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: var(--s-4);
  padding: var(--s-5) var(--s-6);
  border-top: var(--bd);
}

@media (max-width: 640px) {
  .ds-modal-backdrop { padding: 0; align-items: flex-end; }
  .ds-modal { max-width: none; max-height: 94vh; border-radius: var(--r-lg) var(--r-lg) 0 0; }
}

/* =========================================================================
   10. AVISO EN LÍNEA
   El mensaje del sistema y el aviso de contexto. Icono + texto, nunca solo
   color, y el borde izquierdo grueso porque ES la señal, no decoración.
   ========================================================================= */

.ds-note {
  display: flex;
  align-items: flex-start;
  gap: var(--s-5);
  padding: var(--s-5) var(--s-6);
  border-radius: var(--r-sm);
  border-left: var(--bd-w-signal) solid var(--idle);
  background: var(--idle-bg);
  font-size: var(--fs-base);
}

.ds-note--ok   { border-left-color: var(--ok);   background: var(--ok-bg); }
.ds-note--warn { border-left-color: var(--warn); background: var(--warn-bg); }
.ds-note--risk { border-left-color: var(--risk); background: var(--risk-bg); }
.ds-note--info { border-left-color: var(--info); background: var(--info-bg); }

.ds-note__i { flex: none; margin-top: var(--s-1); }
.ds-note__title { font-weight: 600; }

/* =========================================================================
   11. LA AYUDA: PÁGINA DE DOCUMENTACIÓN, MANUAL Y MAQUETAS
   Reconstruido de forma independiente TRES veces (Stock, Gestión Taller,
   Contabilidad) sin reutilizarse nunca: es el caso más claro de componente
   que faltaba en un núcleo. Son capturas dibujadas con CSS, no imágenes, y
   por eso envejecen con el producto en lugar de quedarse desfasadas.

   ---------------------------------------------------------------------
   CÓMO SE DIBUJA UNA MAQUETA (decisión de 2026-08-04)
   ---------------------------------------------------------------------
   Al medir las dos hojas de ayuda que había —`stock/css/ayuda.css` y
   `gestiontaller/css/taller-ayuda.css`— resultaron ser CLONES: las mismas
   `manual-*`, `mock-*` y `ui-mockup*`, con las mismas maquetas dibujadas dos
   veces. Y las dos tenían el mismo defecto de fondo: dentro de la maqueta
   había un juego de piezas FALSAS —`mock-btn`, `mock-input`, `mock-badge`,
   `mock-table`, `mock-kpi`— que imitaban a las de verdad. O sea un segundo
   sistema de diseño, sin mantenimiento, que envejece por su cuenta: en Stock
   está declarado por escrito que las maquetas «retratan la interfaz
   ANTERIOR».

   La regla es: **una maqueta se dibuja con los componentes de verdad**.
   Dentro de un `.ds-mock` van `.ds-table`, `.ds-badge`, `.ds-kpi`,
   `.ds-etapas`, `.ds-btn`… los mismos que pinta la pantalla que se está
   explicando. Así el dibujo no puede desfasarse: cuando el componente cambia,
   la maqueta cambia con él. Lo único que aporta `.ds-mock` es el marco
   discontinuo y el rótulo «Ejemplo», que es lo que impide confundir el dibujo
   con la pantalla — y por eso NO se le quita.

   Corolario: no se añaden aquí `ds-mock__btn` ni parientes. Si una maqueta
   necesita una pieza que el núcleo no tiene, es que falta el componente de
   verdad, no su imitación.
   ========================================================================= */

/* La página de ayuda entera. No es un arquetipo —los cinco arquetipos son para
   pantallas de trabajo— sino una forma de página que TODAS las apps tienen: un
   documento largo con índice, sus secciones y sus maquetas.

   Lo único que necesita del sistema es la anchura de lectura: a ancho completo
   de escritorio saldrían renglones de 200 caracteres. Es más ancha que
   `.ds-manual` (76ch) porque aquí conviven la prosa y tablas de tres columnas.
   Antes cada app lo resolvía con su propio `ayuda-container`/`manual-container`. */
.ds-doc { max-width: 92ch; }

.ds-manual { max-width: 76ch; }

.ds-manual__step {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--s-5);
  padding: var(--s-6) 0;
  border-bottom: var(--bd);
}

.ds-manual__n {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--s-9);
  height: var(--s-9);
  border-radius: var(--r-pill);
  background: var(--n-100);
  color: var(--n-600);
  font-size: var(--fs-sm);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.ds-manual__title { font-weight: 600; }

.ds-manual__text {
  margin-top: var(--s-3);
  color: var(--n-600);
}

/* La maqueta. Deliberadamente apagada y con el rótulo puesto: tiene que
   leerse como un dibujo de la pantalla, no confundirse con la pantalla. */
.ds-mock {
  margin-top: var(--s-5);
  padding: var(--s-5);
  border: 1px dashed var(--n-300);
  border-radius: var(--r-md);
  background: var(--n-25);
  font-size: var(--fs-xs);
  color: var(--n-500);
}

.ds-mock::before {
  content: "Ejemplo";
  display: block;
  margin-bottom: var(--s-4);
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-400);
}

/* =========================================================================
   5.f SELECTOR VISUAL DE PRODUCTO
   Sale de la descripción del flujo real de Stock (2026-08-03): N personas por
   clínica usan la app SOLO para extraer material, muchas veces al día, y no
   se saben las referencias — reconocen la caja. Para ellas la foto no es
   adorno: es el identificador. Un buscador de texto les pide justo el dato
   que no tienen.

   Por eso la tarjeta se ordena: FOTO primero, nombre después, y debajo dónde
   está físicamente. Tocarla suma una unidad; no hay diálogo de cantidad,
   porque el gesto tiene que costar un toque.

   Reutilizable por la tienda del Club, que tiene el mismo problema.
   ========================================================================= */

.ds-picker {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: var(--s-5);
  padding: var(--s-6);
}

.ds-pcard {
  display: flex;
  flex-direction: column;
  border: var(--bd-strong);
  border-radius: var(--r-md);
  background: var(--n-0);
  overflow: hidden;
  cursor: pointer;
  text-align: left;
  padding: 0;
  transition: border-color var(--dur-fast) var(--ease);
}

.ds-pcard:hover { border-color: var(--n-400); }

/* Con unidades cogidas: el acento marca lo que llevas en la cesta, que es el
   único estado que esta pantalla necesita comunicar. */
.ds-pcard[data-cogidas]:not([data-cogidas="0"]) {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}

.ds-pcard__foto {
  position: relative;
  aspect-ratio: 4 / 3;
  background: var(--n-100);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--n-400);
  font-size: var(--fs-2xl);
}

/* `contain` y no `cover`. Estas fotos son packshots —el producto centrado
   sobre fondo blanco—, así que rellenar el hueco recortando corta justo la
   caja que hay que reconocer. Con `contain` se ve entera y conserva su
   proporción, que es lo único que la hace identificable de un vistazo.
   El fondo pasa a blanco porque el del packshot ya lo es: con el gris de la
   caja se veía el recuadro de la foto flotando dentro de la tarjeta. */
.ds-pcard__foto img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: var(--s-4);
}

.ds-pcard:has(img) .ds-pcard__foto { background: var(--n-0); }

/* SIN FOTO NO ES UN ESTADO DEGRADADO.
   Medido en CDT: de los 437 productos que la clínica usa de verdad, 162 no
   tienen foto — y solo 3 tienen URL de la que bajarla. Los que llegan por
   comercial, sin web, no van a tenerla nunca. O sea que esto no es una deuda
   que se salda: es un tercio permanente del catálogo.

   Por eso la tarjeta sin foto NO lleva un hueco gris pidiendo una imagen. Una
   rejilla con un tercio de huecos grises se lee como una app rota, y encima
   castiga justo a los productos del comercial. La tarjeta sin foto cambia de
   identificador: manda EL NOMBRE, grande y legible, que es lo que hay. La
   cámara queda como gesto discreto en una esquina, para quien pueda y quiera.

   Las dos tarjetas son ciudadanas de primera; solo cambian de qué se agarra
   el ojo. */
.ds-pcard__titulo {
  /* El relleno inferior es mayor a propósito: deja sitio al gesto de la
     cámara, que si no se le come la última línea del nombre — y el nombre es
     justamente el identificador de esta tarjeta. */
  padding: var(--s-5) var(--s-5) var(--s-10);
  font-size: var(--fs-md);
  font-weight: 600;
  line-height: var(--lh-tight);
  color: var(--n-700);
  text-align: center;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
}

.ds-pcard--texto .ds-pcard__foto { background: var(--n-50); }

/* Gesto para añadir la foto: discreto y opcional. Quien no encuentra el
   producto es quien lo tiene delante, así que se le ofrece —pero sin convertir
   su ausencia en el mensaje principal de la tarjeta. */
.ds-pcard__camara {
  position: absolute;
  bottom: var(--s-3);
  right: var(--s-3);
  width: var(--s-9);
  height: var(--s-9);
  border-radius: var(--r-pill);
  border: var(--bd-strong);
  background: var(--n-0);
  color: var(--n-500);
  font-size: var(--fs-sm);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

.ds-pcard__camara:hover { color: var(--accent); border-color: var(--accent); }

/* Contador de lo cogido, encima de la foto. */
.ds-pcard__cont {
  position: absolute;
  top: var(--s-3);
  right: var(--s-3);
  min-width: var(--s-9);
  height: var(--s-9);
  padding: 0 var(--s-3);
  border-radius: var(--r-pill);
  background: var(--accent);
  color: var(--n-0);
  font-size: var(--fs-base);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  display: flex;
  align-items: center;
  justify-content: center;
}

.ds-pcard__cuerpo { padding: var(--s-4) var(--s-5) var(--s-5); }

.ds-pcard--texto .ds-pcard__nombre { display: none; }

.ds-pcard__nombre {
  font-size: var(--fs-sm);
  font-weight: 600;
  line-height: var(--lh-tight);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Dónde está físicamente. Es lo segundo que se lee y lo que evita la vuelta
   al almacén con las manos vacías. */
.ds-pcard__donde {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  margin-top: var(--s-3);
  padding: var(--s-1) var(--s-3);
  border-radius: var(--r-sm);
  background: var(--n-50);
  font-size: var(--fs-xs);
  color: var(--n-600);
}

.ds-pcard__quedan {
  margin-top: var(--s-3);
  font-size: var(--fs-xs);
  color: var(--n-500);
  font-variant-numeric: tabular-nums;
}

.ds-pcard__quedan--bajo { color: var(--warn); font-weight: 600; }
.ds-pcard__quedan--cero { color: var(--risk); font-weight: 600; }

/* Barra de la cesta, anclada abajo. Siempre visible en cuanto hay algo:
   confirmar no puede depender de recordar dónde estaba el botón. */
.ds-cesta {
  display: flex;
  align-items: center;
  gap: var(--s-6);
  padding: var(--s-5) var(--s-6);
  border-top: var(--bd-w-signal) solid var(--accent);
  background: var(--accent-bg);
}

.ds-cesta__n { font-weight: 600; font-variant-numeric: tabular-nums; }
.ds-cesta__acciones { margin-left: auto; display: flex; gap: var(--s-4); }

@media (max-width: 640px) {
  .ds-picker { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: var(--s-4); padding: var(--s-4); }
}

/* -------------------------------------------------------------------------
   5.f.2 TARJETA DE ESPECIFICACIÓN — cuando la foto no puede distinguir
   Descubierto midiendo el catálogo real (2026-08-03). Los 31 implantes en uso
   de CDT no tienen foto, y NO es dejadez: un BioHorizons 3,3×10,5 y uno
   3,8×12,0 son la misma fotografía. Ahí la foto no identifica nada.

   Pero el nombre tampoco, tal cual: «Implante BioHorizons Tappered Pro
   Conical 3,3x10.5mm Narrow» son 55 caracteres de los que solo discriminan
   los 15 últimos, y los otros 40 se repiten idénticos en las 31 filas. Una
   rejilla así obliga a leer la cola de cada tarjeta — y recortar el texto,
   que es lo natural, corta EXACTAMENTE por donde está el dato que importa.

   Solución: sacar el prefijo común a una cabecera de grupo y dejar en la
   tarjeta solo lo que varía, grande y en numeración tabular para que las
   medidas se comparen en vertical. El prefijo se calcula solo (prefijo común
   más largo del grupo); no hay que teclear nada por producto.
   ------------------------------------------------------------------------- */

/* Cabecera de familia dentro de la rejilla. Lleva el prefijo que se ha
   factorizado, para que nadie tenga que adivinar de qué son estas tarjetas. */
.ds-picker__familia {
  grid-column: 1 / -1;
  display: flex;
  align-items: baseline;
  gap: var(--s-4);
  padding: var(--s-6) var(--s-1) var(--s-2);
  border-bottom: var(--bd);
}

.ds-picker__familia-nombre {
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-500);
}

.ds-picker__familia-n {
  margin-left: auto;
  font-size: var(--fs-xs);
  color: var(--n-400);
  font-variant-numeric: tabular-nums;
}

/* La medida manda: es lo único que distingue una caja de la de al lado. */
.ds-pcard--spec .ds-pcard__foto { background: var(--n-25); }

.ds-pcard__spec {
  padding: var(--s-5);
  font-size: var(--fs-lg);
  font-weight: 600;
  line-height: var(--lh-tight);
  color: var(--n-800);
  text-align: center;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}

/* Matiz de la variante (Narrow / Regular / Wide): distingue tanto como la
   medida, pero no se lee como número. */
.ds-pcard__spec-var {
  display: block;
  margin-top: var(--s-2);
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-500);
}

.ds-pcard--spec .ds-pcard__nombre { display: none; }

/* =========================================================================
   12. VUELTA
   El sistema no tenía forma de volver. Ni un componente, ni un hueco donde
   ponerlo: `grep volver static/design/` no devolvía nada, así que las
   pantallas migradas heredaban la omisión una por una — y el contrato del
   arquetipo B la convertía en regla, retirando el botón «Volver» de la ficha
   porque competía con la acción de acento.

   Competir competía, y por eso esto NO es un botón. Es un enlace pequeño,
   gris y por encima del título: se lee antes que nada al entrar (arriba a la
   izquierda es donde el ojo empieza) y no disputa el peso visual de la
   siguiente acción, que sigue estando sola a la derecha. La regla de §3-B se
   mantiene entera; lo que se repara es la salida.

   Destino FIJO Y NOMBRADO («Todos los trabajos»), no `history.back()`:
   - `history.back()` miente cuando se llega por enlace directo desde
     WhatsApp, desde un correo o desde una pestaña nueva, que es justo como
     se llega a estas fichas.
   - Un destino nombrado se puede leer antes de pulsarlo. Es lo que hace que
     la app se pueda dibujar de memoria, y es la misma razón por la que el
     nivel 2 de la navegación se declara una sola vez.

   No sustituye a la navegación de arriba: la de arriba dice dónde estás en
   la app, esta dice de dónde vienes en este trabajo.
   ========================================================================= */

.ds-volver {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: 1;
  color: var(--n-500);
  text-decoration: none;
  border-radius: var(--r-sm);
  transition: color var(--dur-fast) var(--ease);
}

.ds-volver:hover {
  color: var(--n-800);
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* El foco lo pinta `base.css`, pero solo dentro de `.ds-app`. Esta pieza se
   usa también en pantallas todavía sin migrar (es la salida de emergencia:
   negarle el anillo justo ahí sería negárselo a quien navega con teclado en
   la mitad del producto que aún no ha llegado al sistema). */
.ds-volver:focus-visible {
  outline: none;
  box-shadow: var(--focus);
}

/* La flecha no es decoración: sin ella el enlace se lee como un título de
   sección. Va en un span propio para que no herede el subrayado del hover,
   que sobre una flecha se ve como un tachón. */
.ds-volver__i {
  font-size: var(--fs-xs);
  line-height: 1;
}

.ds-volver:hover .ds-volver__i { text-decoration: none; }

/* Cuando va SOLO en su renglón, encima del título de una pantalla que NO es
   arquetipo B (el B reparte el espacio de su cabecera en `.ds-b__volver`).
   Sin el modificador, el enlace comparte barra con otros datos —el resumen de
   una lista— y ahí la separación la pone la barra. */
.ds-volver--bloque {
  display: flex;
  width: fit-content;
  margin-bottom: var(--s-3);
}

/* =========================================================================
   13. GRÁFICO
   Lo pide Analytics (arquetipo C, 2026-08-06), y no es una pieza suya: el
   contenedor de gráfico está reinventado en CUATRO hojas de app —analytics,
   cortex, contabilidad y padi— con el mismo nombre (`chart-container`) y
   tres alturas distintas. Por eso vive aquí y no en el arquetipo: Contabilidad
   es arquetipo A y también dibuja.

   Lo único que aporta es un ALTO RESERVADO. Sin él, la biblioteca de gráficos
   pinta sobre un contenedor de altura 0 y la página da un salto cuando llega
   el dibujo; con el alto puesto, el hueco ya está antes de que cargue nada.
   El color y la tipografía de las series NO se deciden aquí —las decide quien
   dibuja—, pero salen de estos mismos tokens (ver
   static/analytics/js/charts_paleta.js).
   ========================================================================= */

.ds-chart {
  position: relative;
  width: 100%;
  max-width: 100%;
  min-height: 260px;
  overflow: hidden;
}

/* Series muchas o categorías en el eje Y: el alto lo calcula quien dibuja y
   el contenedor deja de imponer el suyo. */
.ds-chart--auto { min-height: 0; }

/* Mapas de calor y barras horizontales con muchas categorías. */
.ds-chart--alto { min-height: 420px; }

/* La biblioteca escribe anchos en línea sobre su propio nodo al redimensionar
   y, si la ventana encoge, se queda con el ancho anterior y desborda al
   padre. Este es el único sitio del sistema donde hace falta contener a un
   tercero que escribe estilo en línea. */
.ds-chart > div { max-width: 100%; }
.ds-chart canvas,
.ds-chart svg,
.ds-chart foreignObject { max-width: 100%; }

/* PIE DEL GRÁFICO. No es decoración: es dónde se dice qué mide el eje y de
   dónde sale el dato, que en un panel analítico es la mitad de la lectura.
   Va DEBAJO y no en un `title`, porque en tableta el `title` no existe (§6). */
.ds-chart__pie {
  margin-top: var(--s-4);
  font-size: var(--fs-xs);
  line-height: var(--lh-base);
  color: var(--n-500);
}

/* Serie etiquetada a mano, para los gráficos que §3-C quiere SIN leyenda.
   Es un rótulo con su muestra de color, no una leyenda: se pone junto al
   dato, no en una esquina que obliga a mirar dos veces. */
.ds-serie {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  font-size: var(--fs-xs);
  color: var(--n-600);
}

.ds-serie__marca {
  width: 10px;
  height: 10px;
  flex: none;
  border-radius: var(--r-sm);
  background: var(--n-300);
}

/* La muestra toma el color de la MISMA ranura que la serie del gráfico, y por
   eso se elige por número y no con un `style` en línea: la tabla de debajo y
   el gráfico de arriba tienen que estar pintando lo mismo del mismo color, y
   un literal en el marcado no puede garantizarlo.

   Sin modificador se queda gris, que es lo correcto de la SEXTA en adelante:
   no hay ranura nueva que inventar (ver tokens.css, §series de gráfico). */
.ds-serie__marca--1 { background: var(--viz-1); }
.ds-serie__marca--2 { background: var(--viz-2); }
.ds-serie__marca--3 { background: var(--viz-3); }
.ds-serie__marca--4 { background: var(--viz-4); }
.ds-serie__marca--5 { background: var(--viz-5); }
/* De la sexta en adelante NO hay modificador, y por eso la muestra se queda
   gris: es el mismo «Otros» que devuelve `LydentViz.colores()`. Una tabla con
   nueve filas de colores distintos no se está leyendo, se está hojeando. */

/* En móvil el gráfico baja de alto: a 260px de alto y 340 de ancho, un
   gráfico ocupa media pantalla y empuja fuera del pliegue lo que explica. */
@media (max-width: 640px) {
  .ds-chart       { min-height: 220px; }
  .ds-chart--alto { min-height: 320px; }
}

/* =========================================================================
   14. CONTROL SEGMENTADO
   Elegir UNA de entre pocas opciones que no cambian de página: la métrica de
   un gráfico, el ámbito de un informe, la ventana de comparación. Está
   reinventado en tres hojas —analytics (`metrica-toggle`), comercial y padi—
   y las tres lo montan con `<input type="radio">` oculto y un `<span>`
   pintado, que es lo correcto: es un grupo de radios, y hacerlo con botones
   pierde las flechas del teclado y el anuncio del lector de pantalla.

   No es `.ds-tabs`: las pestañas navegan a otra pantalla y aquí no se
   navega. No es `.ds-btngroup` con `aria-pressed`: eso son acciones, y esto
   es un valor que se envía con el formulario.
   ========================================================================= */

.ds-seg {
  display: inline-flex;
  padding: var(--s-1);
  gap: var(--s-1);
  background: var(--n-100);
  border-radius: var(--r-md);
}

.ds-seg__op {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: calc(var(--ctl-h) - var(--s-2));
  padding: 0 var(--s-5);
  border-radius: var(--r-sm);
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--n-600);
  white-space: nowrap;
  cursor: pointer;
}

/* El radio de verdad sigue ahí y sigue recibiendo el foco: se saca de la
   vista sin `display:none`, que lo quitaría del orden de tabulación. */
.ds-seg__op input {
  position: absolute;
  opacity: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  cursor: pointer;
}

.ds-seg__op:hover { color: var(--n-900); }

.ds-seg__op:has(input:checked) {
  background: var(--n-0);
  color: var(--n-900);
  box-shadow: 0 0 0 1px var(--n-200);
}

.ds-seg__op:has(input:focus-visible) { box-shadow: var(--focus); }

/* A 375px cuatro métricas no caben en una fila: envuelve en vez de salirse. */
@media (max-width: 640px) {
  .ds-seg { flex-wrap: wrap; }
}

/* =========================================================================
   15. PISTA
   El icono de interrogación que explica cómo se calcula una cifra. Analytics
   tiene setenta, y estaban todos en `data-tooltip` sobre un `<i>`: en tableta
   —que es la mitad del uso en gabinete— no hay `hover`, o sea que esa
   explicación NO EXISTÍA (§6, y es la misma regla que ya se cobró en la
   columna de acciones del Club).

   Se arregla sin JS y sin dependencias: el disparador es un `<button>`, así
   que se puede TOCAR y TABULAR, y el texto aparece con `:hover`, `:focus-visible`
   y `:focus-within`. Un toque en el móvil enfoca el botón y muestra la pista;
   el siguiente toque fuera la cierra.

   El texto va en el marcado, no en un atributo: un `data-tooltip` no lo lee
   un lector de pantalla ni lo encuentra Ctrl+F.
   ========================================================================= */

.ds-hint { position: relative; display: inline-flex; }

.ds-hint__b {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.25rem;
  height: 1.25rem;
  padding: 0;
  border: 0;
  border-radius: var(--r-pill);
  background: none;
  color: var(--n-400);
  font-size: var(--fs-xs);
  line-height: 1;
  cursor: help;
}

.ds-hint__b:hover { color: var(--n-700); background: var(--n-100); }

.ds-hint__txt {
  position: absolute;
  top: calc(100% + var(--s-3));
  left: 50%;
  z-index: 30;
  width: max-content;
  max-width: min(34ch, 80vw);
  transform: translateX(-50%);
  padding: var(--s-4) var(--s-5);
  border-radius: var(--r-md);
  background: var(--n-800);
  color: var(--n-0);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 400;
  line-height: var(--lh-base);
  letter-spacing: normal;
  text-align: left;
  text-transform: none;
  box-shadow: var(--sh-overlay);
  visibility: hidden;
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease);
}

.ds-hint:hover .ds-hint__txt,
.ds-hint:focus-within .ds-hint__txt {
  visibility: visible;
  opacity: 1;
}

/* Pegada al borde derecho de la pantalla, una pista centrada se sale. En
   móvil se ancla al borde del disparador y no al centro. */
@media (max-width: 640px) {
  .ds-hint__txt {
    left: auto;
    right: 0;
    transform: none;
  }
}

/* =========================================================================
   16. SELECCIÓN MÚLTIPLE CON BÚSQUEDA
   Elegir varias cosas de una lista larga: las áreas que se comparan en un
   informe, los destinatarios de un envío. Sube al núcleo el 2026-08-06, al
   cerrar la migración de Analytics: vivía en `analytics.css` como `.ms-*` y
   esa hoja se borra, pero el componente no es de esa app —Comercial tiene su
   propia versión— y dejarlo en `apps/analytics.css` habría sido esconder la
   duplicación en otro sitio.

   El `<select multiple hidden>` de verdad sigue en el marcado y es la fuente
   que se envía: esto lo viste y lo maneja, no lo sustituye. Si el JavaScript
   falla, el formulario sigue funcionando.
   ========================================================================= */

.ds-multi { position: relative; flex: 0 0 300px; min-width: 260px; }

.ds-multi__trigger {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  width: 100%;
  min-height: var(--ctl-h);
  padding: var(--s-2) var(--s-5);
  border: var(--bd-strong);
  border-radius: var(--r-sm);
  background: var(--n-0);
  font: inherit;
  font-size: var(--fs-sm);
  text-align: left;
  cursor: pointer;
}

.ds-multi__trigger:hover { border-color: var(--n-400); }
.ds-multi__trigger[aria-expanded="true"] { border-color: var(--accent); }

/* Lo elegido, dentro del propio disparador: sin esto hay que abrir el panel
   para saber qué está puesto, y un filtro olvidado es una tabla que parece
   vacía. */
.ds-multi__pills {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
  flex: 1;
  min-height: 22px;
}

.ds-multi__pills--empty { color: var(--n-400); }

.ds-multi__pill {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-1) var(--s-3);
  border-radius: var(--r-pill);
  background: var(--n-100);
  font-size: var(--fs-xs);
  font-weight: 600;
}

/* La pastilla toma el color de la serie que le corresponde en el gráfico. Lo
   pone el JS con `--pill-color`, que es un DATO —qué área es la tercera— y no
   una decisión de estilo: por eso viaja en una variable y no en una clase. */
.ds-multi__pill[style*="--pill-color"] { background: var(--pill-color); color: var(--n-0); }

.ds-multi__pill-x {
  display: inline-flex;
  border: 0;
  padding: 0;
  background: none;
  color: inherit;
  font-size: var(--fs-base);
  line-height: 1;
  cursor: pointer;
  opacity: 0.8;
}

.ds-multi__pill-x:hover { opacity: 1; }

.ds-multi__counter {
  flex: none;
  font-size: var(--fs-micro);
  font-variant-numeric: tabular-nums;
  color: var(--n-500);
}

.ds-multi__caret { flex: none; color: var(--n-400); font-size: var(--fs-xs); }

.ds-multi__panel {
  position: absolute;
  top: calc(100% + var(--s-2));
  left: 0;
  z-index: 20;
  width: 100%;
  min-width: 280px;
  padding: var(--s-4);
  border: var(--bd-strong);
  border-radius: var(--r-md);
  background: var(--n-0);
  box-shadow: var(--sh-overlay);
}

.ds-multi__search { width: 100%; margin-bottom: var(--s-4); }

.ds-multi__options { max-height: 260px; overflow-y: auto; }

.ds-multi__option {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-sm);
  font-size: var(--fs-sm);
  cursor: pointer;
}

.ds-multi__option:hover { background: var(--n-50); }

/* Al llegar al tope no se ocultan las demás: se apagan. Que desaparezcan hace
   creer que la lista se ha filtrado sola. */
.ds-multi__option[data-tope="true"] { opacity: 0.45; cursor: not-allowed; }

.ds-multi__foot {
  display: flex;
  justify-content: space-between;
  gap: var(--s-4);
  margin-top: var(--s-4);
  padding-top: var(--s-4);
  border-top: var(--bd);
}

@media (max-width: 640px) {
  .ds-multi { flex: 1 1 100%; max-width: none; }
  .ds-multi__panel { position: static; box-shadow: none; margin-top: var(--s-3); }
}


/* =========================================================================
   17 y 18 — vinieron de `archetypes/b-flujo.css` el 2026-08-06, al migrar
   Comercial. Se escribieron allí porque las pidió la ficha de producto de
   Stock (un expediente), pero no son propias de un expediente: la FICHA DEL
   PACIENTE es arquetipo C y necesita las dos —el bloque «pasado» se pliega y
   el historial de contactos es una cronología de lectura—, y de B no hereda
   nada. Mismo criterio con el que Analytics subió `.ds-table--responsive`.
   ========================================================================= */

/* =========================================================================
   17. SECCIÓN PLEGABLE
   «Todo lo demás se pliega». Con <details>/<summary> nativos: lo que hace el
   navegador, lo hace el navegador (DESIGN.md §2).
   ========================================================================= */

.ds-bloque {
  border: var(--bd-strong);
  border-radius: var(--r-md);
  background: var(--n-0);
}

.ds-bloque > summary {
  display: flex;
  align-items: center;
  gap: var(--s-5);
  padding: var(--s-5) var(--s-6);
  cursor: pointer;
  list-style: none;
  font-weight: 600;
}

.ds-bloque > summary::-webkit-details-marker { display: none; }

.ds-bloque > summary::before {
  content: "";
  width: 0;
  height: 0;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  border-left: 5px solid var(--n-400);
  transition: transform var(--dur-fast) var(--ease);
}

.ds-bloque[open] > summary::before { transform: rotate(90deg); }

.ds-bloque > summary:hover { background: var(--n-25); }

/* Recuento a la derecha del título: se lee sin desplegar, que es justo lo que
   permite dejarlo plegado sin perder información. */
.ds-bloque__cuenta {
  margin-left: auto;
  font-size: var(--fs-xs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--n-500);
}

.ds-bloque__cuerpo {
  padding: var(--s-6);
  border-top: var(--bd);
}

.ds-bloque__cuerpo--flush { padding: 0; }

/* =========================================================================
   18. CRONOLOGÍA
   El cuerpo del arquetipo. La semilla real del repositorio es `episodio.css`
   de Coordinador; aquí se generaliza y se le corrige el defecto que el
   inventario le encontró: sus puntos comunicaban el estado solo con color.
   ========================================================================= */

.ds-crono {
  list-style: none;
  margin: 0;
  padding: 0;
}

.ds-crono__hito {
  position: relative;
  padding: 0 0 var(--s-7) var(--s-8);
  border-left: 1px solid var(--n-200);
}

.ds-crono__hito:last-child { border-left-color: transparent; padding-bottom: 0; }

.ds-crono__punto {
  position: absolute;
  left: calc(var(--s-2) * -1 - 1px);
  top: var(--s-1);
  width: var(--s-5);
  height: var(--s-5);
  border-radius: var(--r-pill);
  background: var(--n-0);
  border: 2px solid var(--n-300);
}

.ds-crono__hito--ok   .ds-crono__punto { border-color: var(--ok); background: var(--ok); }
.ds-crono__hito--warn .ds-crono__punto { border-color: var(--warn); background: var(--warn); }
.ds-crono__hito--risk .ds-crono__punto { border-color: var(--risk); background: var(--risk); }

.ds-crono__fecha {
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-500);
  font-variant-numeric: tabular-nums;
}

.ds-crono__texto { margin-top: var(--s-2); }
.ds-crono__quien { margin-top: var(--s-2); font-size: var(--fs-xs); color: var(--n-500); }

/* =========================================================================
   19. VISTA PREVIA DE MENSAJE
   Lo que se va a mandar, ya con las variables de la plantilla sustituidas.
   No es un campo más del formulario: es la única oportunidad de leer el
   mensaje como lo va a leer el paciente, y por eso se saca del flujo de
   campos y se enmarca.

   Deliberadamente NO imita a WhatsApp: la versión anterior copiaba el papel
   pintado (#ECE5DD), la burbuja blanca y el pico, y eso es marca ajena dentro
   de una interfaz que tiene un solo acento (DESIGN.md §5). Lo que hace falta
   comunicar —«esto es un mensaje, no un input»— lo dice el marco.

   Sirve para cualquier canal (WhatsApp, SMS, correo, chat): el canal lo dice
   el título del modal, no el color de la caja.
   ========================================================================= */

.ds-msgprev {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

.ds-msgprev__label {
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-500);
}

/* `pre-wrap` porque los saltos de línea del mensaje son contenido: una
   plantilla con dos párrafos llega con dos párrafos. El ancho se acota en
   caracteres, no en píxeles, porque lo que se está previsualizando es texto
   corrido y en un modal de 880 px una línea de 120 caracteres no se parece
   en nada a la que va a ver el paciente en el móvil. */
.ds-msgprev__texto {
  max-width: 48ch;
  padding: var(--s-4) var(--s-5);
  border: var(--bd-strong);
  border-radius: var(--r-sm);
  background: var(--n-25);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  line-height: var(--lh-base);
}

/* LA IMAGEN QUE VIAJA CON EL MENSAJE (2026-08-09, con las campañas de
   fidelización de Comercial, donde lo que se envía ES una tarjeta con su pie).
   Se acota al mismo ancho que el texto: es la misma pieza, y verla más grande
   que el mensaje que la acompaña invierte el peso de lo que se está revisando.
   Va aquí y no en `apps/comercial.css` porque previsualizar un adjunto es del
   canal, no de la app: Mensajería y Recordatorios mandan imágenes igual. */
.ds-msgprev__img {
  max-width: min(100%, 48ch);
  height: auto;
  border: var(--bd-strong);
  border-radius: var(--r-sm);
  display: block;
}

/* =========================================================================
   20. MANDO DE SIMULADOR
   Lo pidió el cuadro de mando de Cortex, que es el primer simulador del
   producto —cuatro ejes: PV, revisiones, urgencias y precio—. No es suyo: el
   simulador de negocio de Analytics y el de escenarios de pagos piden lo
   mismo, así que vive aquí y no en `apps/cortex.css`.

   `input[type=range]` no estaba vestido en ninguna parte del núcleo, así que
   hasta hoy salía con el aspecto por defecto del navegador — que es distinto
   en cada uno.
   ========================================================================= */

.ds-mando {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding-block: var(--s-3);
}

/* El rótulo y su valor comparten renglón: la cifra es lo que se mira al
   arrastrar, así que no puede estar lejos del pulgar. */
.ds-mando__label {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-3);
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-600);
}

.ds-mando__val {
  font-family: var(--font-ui);
  font-size: var(--fs-md);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
  text-transform: none;
  color: var(--n-900);
}

.ds-mando__input {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 1.25rem;
  background: transparent;
  cursor: pointer;
}

/* El carril y el pulgar hay que declararlos por separado en cada motor: no
   existe una propiedad que valga para los dos. */
.ds-mando__input::-webkit-slider-runnable-track {
  height: 0.25rem;
  border-radius: var(--r-1);
  background: var(--n-200);
}
.ds-mando__input::-moz-range-track {
  height: 0.25rem;
  border-radius: var(--r-1);
  background: var(--n-200);
}

.ds-mando__input::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 1rem;
  height: 1rem;
  margin-top: -0.375rem;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--n-0);
}
.ds-mando__input::-moz-range-thumb {
  width: 1rem;
  height: 1rem;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--n-0);
}

.ds-mando__input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.ds-mando__input:disabled { cursor: not-allowed; opacity: 0.5; }


/* =========================================================================
   21. ESCALA — un valor frente a su referencia
   «Yo» contra «la media de mis compañeros» sobre una regla. Lo pidió el
   cuadro de mando de Cortex, donde vivía como `cm-peers-bar` con los
   marcadores posicionados por `style="left: calc(...)"` ESCRITO EN LA
   PLANTILLA, o sea CSS en línea calculado en el servidor.

   Sigue haciendo falta pasar la posición desde el servidor —es un dato, no
   una decisión de estilo—, pero ahora va por una variable propia
   (`--ds-escala-pos`) en vez de por una propiedad de layout: la plantilla
   dice DÓNDE está el valor y la hoja decide cómo se dibuja eso.
   ========================================================================= */

.ds-escala {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.ds-escala__track {
  position: relative;
  height: 0.375rem;
  border-radius: var(--r-1);
  background: var(--n-100);
}

.ds-escala__marca {
  position: absolute;
  top: 50%;
  left: var(--ds-escala-pos, 0%);
  width: 0.75rem;
  height: 0.75rem;
  margin-left: -0.375rem;
  border-radius: 50%;
  transform: translateY(-50%);
  border: 2px solid var(--n-0);
}

.ds-escala__marca--propio { background: var(--accent); z-index: 1; }
.ds-escala__marca--ref { background: var(--n-400); }

.ds-escala__pies {
  display: flex;
  justify-content: space-between;
  font-size: var(--fs-xs);
  color: var(--n-500);
}

/* La leyenda lleva PALABRA, no solo el punto de color: dos círculos que solo
   se distinguen por el tono no los lee un daltónico (§5). */
.ds-escala__leyenda {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
}

.ds-escala__punto {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  display: inline-block;
}
.ds-escala__punto--propio { background: var(--accent); }
.ds-escala__punto--ref { background: var(--n-400); }


/* =========================================================================
   22. IMPRESIÓN
   Añadido el 2026-08-07, con el guion de grabación de Claudia Voz — que es una
   pantalla cuyo destino ES el papel: se lee en voz alta delante de un
   micrófono, y con el móvil en la mano no se graba.

   Sube al núcleo, y no se queda en la hoja de esa app, porque no tiene nada de
   suyo: apagar el armazón al imprimir vale para las trece. Era además la
   tercera vez que hacía falta —el dossier de PV y los documentos PDF lo
   resuelven cada uno por su cuenta— aunque esos dos son otra cosa: los genera
   WeasyPrint desde el servidor y no pasan por aquí. Esto es para las pantallas
   que se imprimen DESDE EL NAVEGADOR.

   `body:has(.ds-app)` mantiene la regla inerte donde el sistema todavía no se
   ha pedido (§2): una pantalla sin migrar sigue imprimiéndose como siempre.
   Sin `!important` a propósito —la única excepción escrita sigue siendo
   `.ds-hidden`—; el peso lo da la especificidad, y por eso este bloque va al
   final del fichero.
   ========================================================================= */

@media print {
  body:has(.ds-app) .sidebar-universal,
  body:has(.ds-app) .sidebar-overlay,
  body:has(.ds-app) .top-bar,
  body:has(.ds-app) .action-bar,
  body:has(.ds-app) .skip-link,
  .ds-app .ds-tabs,
  .ds-app .ds-filters,
  .ds-app .ds-volver { display: none; }

  /* Lo que la pantalla declara que no se imprime: avisos, cifras de contexto y
     la propia acción de imprimir. Es marcado y no una lista de componentes
     porque solo la pantalla sabe qué de lo suyo sobra en papel.

     EL SEGUNDO SELECTOR NO SOBRA, y se comprobó midiendo: los arquetipos cargan
     DESPUÉS de este fichero, así que `.ds-b__accion { display: flex }` empataba
     en especificidad y ganaba por posición — el botón de imprimir se imprimía—.
     Con `.ds-app` delante sube a (0,2,0) y gana sin `!important`, cuya única
     excepción escrita sigue siendo `.ds-hidden`. Los componentes de ESTE
     fichero no necesitaban el refuerzo (van antes y pierden por posición), pero
     se cubren igual: la diferencia no se puede recordar al escribir marcado. */
  .ds-no-imprimir,
  .ds-app .ds-no-imprimir { display: none; }

  /* Las regiones con scroll dejan de recortar: en papel no hay scroll, y lo
     que no cabe en la caja simplemente no se imprimiría. */
  .ds-app .ds-a__body,
  .ds-app .ds-b__cuerpo,
  .ds-app .ds-c__body { overflow: visible; height: auto; }

  /* Ni sombras ni fondos de pantalla: tinta gastada en decoración. */
  .ds-app .ds-panel { border-color: var(--n-300); box-shadow: none; }
}

/* =========================================================================
   14. CONFIRMACIÓN ESCRITA
   La pieza que `archetypes/e-formulario.css` dejó anotada como pendiente:
   §3-E pide que «las acciones destructivas o de alcance global pidan
   confirmación escrita», y ahí se decidió que su sitio era el núcleo —es un
   componente, no una regla de layout— y su caso, la app Configuración,
   «donde hay más de uno».

   ESCRITO EL 2026-08-09, con ese caso ya en la mesa: el panel de Seguridad
   tiene un botón «Limpiar todo» que borra TODOS los registros de intentos
   fallidos, y su única defensa era un `confirm()` del navegador. Un
   `confirm()` se despacha con la barra espaciadora sin haberlo leído; teclear
   una palabra obliga a mirar qué se va a borrar.

   Marcado:

     <form method="post" action="…" class="ds-confirm" data-confirm="BORRAR">
       {% csrf_token %}
       <p class="ds-confirm__aviso">Esto borra … y no se puede deshacer.</p>
       <label class="ds-field__label" for="x">Escribe BORRAR para continuar</label>
       <input class="ds-input ds-confirm__campo" id="x" autocomplete="off">
       <button class="ds-btn ds-btn--danger ds-confirm__ok" disabled>Borrar</button>
     </form>

   El botón nace `disabled` en el HTML a propósito: si el JS no llega a
   cargarse, la acción no se puede disparar. La alternativa —habilitado y que
   el JS lo deshabilite— deja la puerta abierta justo cuando algo ha fallado.
   ========================================================================= */

.ds-confirm {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  padding: var(--s-5);
  border: var(--bd-w-signal) solid var(--risk);
  border-radius: var(--r-sm);
  background: var(--risk-bg);
}

.ds-confirm__aviso {
  font-size: var(--fs-sm);
  color: var(--risk);
}

.ds-confirm__campo { max-width: 22ch; }

/* Deshabilitado NO es solo un color más flojo: el cursor también lo dice, que
   es lo que se nota antes de leer. */
.ds-app .ds-confirm__ok:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* =========================================================================
   23. ZONA DE SOLTAR UN FICHERO
   Sube al núcleo el 2026-08-09, con PADI. El criterio de §2 —un componente
   entra cuando el repositorio ya lo tiene escrito varias veces— se cumple con
   holgura: hay SEIS implementaciones independientes del mismo gesto
   (`padi_dropzone.js`, `contabilidad.js`, `mensajeria.js`, los dos asistentes
   de Gestión Taller y el formulario de laboratorio de Configuración), cada una
   con su nombre de clase y su color de resalte.

   Gestión Taller ya la había resuelto dentro del sistema nuevo con dos reglas
   en su hoja de app (`.taller-drop`), y PADI la necesitaba idéntica: es el
   momento exacto en que copiar habría sido la segunda bifurcación. Se
   generaliza aquí y Taller pasa a consumirla — el mismo movimiento que hizo
   `includes/pagination_ds.html` al llegar su segundo consumidor.

   Se monta SOBRE `.ds-empty`, que ya centra icono, título y texto: lo único
   que aporta esto es el marco discontinuo —que es lo que dice «aquí cabe algo
   que todavía no está»— y el acuse del arrastre.

   El elemento es un `<label for="…">` con su `<input type="file" hidden>`
   dentro: así se abre el selector con un clic Y con el teclado sin escribir
   una línea de JS. El JS solo hace falta para el arrastre, que no tiene
   equivalente nativo.
   ========================================================================= */

.ds-drop {
  border: 1px dashed var(--n-300);
  border-radius: var(--r-md);
  cursor: pointer;
}

/* Encima del sitio, con el fichero cogido. Es acuse de un gesto en curso, no
   un estado de la pantalla, así que aquí el acento sí vale: nadie lo ve sin
   estar arrastrando algo. */
.ds-drop--activa {
  border-color: var(--accent);
  background: var(--accent-bg);
}

/* Los ficheros elegidos, debajo del texto. Va en tabular porque suelen ser
   nombres con fechas y números de consulta que se comparan de un vistazo. */
.ds-drop__ficheros {
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  color: var(--n-600);
  overflow-wrap: anywhere;
}

/* =========================================================================
   24. ACUSE FLOTANTE
   Añadido el 2026-08-09, con Mensajería. Una pantalla que actúa sin recargar
   —enviar un mensaje, guardar una etiqueta, vincular a un seguimiento— no
   tiene dónde poner el «hecho» de Django, así que cada app que lo necesitaba
   se inventaba el suyo. Aquí no es del arquetipo D: lo pide cualquier
   pantalla que escriba por AJAX.

   Dos reglas que lo hacen honesto:
   - **El error no se cuenta aquí.** Un acuse se va solo a los tres segundos,
     así que solo puede llevar lo que da igual perderse. Un fallo que exige
     hacer algo va en `.ds-note` dentro de la pantalla, donde se queda. La
     variante `--risk` existe para el fallo que ya no tiene arreglo («no se
     pudo enviar»), no para el que lo tiene.
   - **`aria-live`**, o para quien usa lector de pantalla el acuse no existe.
   ========================================================================= */

.ds-toast {
  position: fixed;
  left: 50%;
  bottom: var(--s-8);
  z-index: 1200;
  max-width: 44ch;
  padding: var(--s-5) var(--s-7);
  border-radius: var(--r-md);
  background: var(--n-800);
  color: var(--n-0);
  font-size: var(--fs-base);
  box-shadow: var(--sh-overlay);
  opacity: 0;
  transform: translateX(-50%) translateY(var(--s-8));
  transition: opacity var(--dur-base) var(--ease),
              transform var(--dur-base) var(--ease);
  pointer-events: none;
}

.ds-toast--visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* El fallo lleva icono además de color, como todo estado del sistema (§5). */
.ds-toast--risk { background: var(--risk); }

/* =========================================================================
   25. NAVEGACIÓN INFERIOR — el armazón de una app instalada en el móvil

   La escribe el núcleo y no una app porque ya se había reconstruido DOS veces
   por separado (`static/club_dental/css/club_dental.css` y su gemelo de
   estética), que es el mismo camino por el que la página de ayuda acabó
   escrita tres veces (§11). La tercera es la del Coordinador, y aquí se para.

   Es chrome de MÓVIL, no un componente de pantalla: por encima del punto de
   ruptura de móvil desaparece y manda la fila de secciones (`ds-tabs`), que es
   donde el sistema pone la navegación cuando hay ancho. Una barra inferior en
   un monitor de 27" no es una decisión, es un descuido.

   ⚠️ EL RELEVO — `.ds-nav-relevo` (2026-08-17, con la app del doctor)

   Esa frase de arriba —«manda la fila de secciones»— era una intención, no un
   mecanismo: el núcleo esconde la barra inferior por encima de 640 px y **nadie
   enseñaba la otra**. Cada armazón tenía que pintar las DOS a la vez, así que en
   el teléfono se pintaban las dos: en la app del doctor, los mismos cuatro
   destinos arriba y abajo, y ese alto es justo el que falta con el teclado
   abierto (DESIGN.md §6.1). Y al retirar la de arriba, medido en el armazón
   real, quedaban **0 destinos alcanzables a 1.440 px** —el armazón del doctor se
   parte por ROL, así que un doctor con ordenador es un caso real, no
   hipotético—.

   Lo que faltaba era la mitad complementaria de la pareja, y va **aquí**, dentro
   de este mismo `@media`: el armazón marca su fila de ancho con
   `ds-nav-relevo` y el sistema la esconde **exactamente donde enseña la barra
   inferior**. Una sola condición de anchura gobierna las dos, escrita una vez y
   en el núcleo. Un armazón que copiase el literal `641px` en su hoja de app
   tendría el umbral escrito dos veces —y DESIGN.md §6 solo se lo permite a
   `base.css` y a `archetypes/`—: el día que el punto de ruptura se moviera, las
   dos barras aparecerían juntas otra vez, o ninguna.

   No declara `display` en su estado visible **a propósito**: se compone con lo
   que sea la fila (hoy siempre `ds-tabs`, que ya es `flex` y envuelve), así que
   el relevo no puede romper su maquetación ni duplicar su valor. Es solo la
   condición.
   ========================================================================= */

.ds-nav-inferior { display: none; }

@media (max-width: 640px) {
  .ds-nav-inferior {
    position: fixed;
    inset: auto 0 0 0;
    z-index: 900;
    display: flex;
    /* El indicador de inicio de iOS se come la última fila si no se aparta. */
    padding-bottom: env(safe-area-inset-bottom, 0px);
    border-top: var(--bd);
    background: var(--n-0);
  }

  /* La otra mitad de la pareja: donde entra la barra inferior, se retira la
     fila de ancho del armazón. Va en este mismo bloque para que las dos
     dependan de UNA condición (ver el porqué en la cabecera de la sección).
     Gana a `.ds-tabs { display: flex }` por posición —misma especificidad, y
     esta sección va después—; no se sube a `!important` por eso mismo. */
  .ds-nav-relevo { display: none; }

  .ds-nav-inferior__item {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--s-2);
    /* 44px de objetivo táctil (§6), aunque el modo compacto esté activo. */
    min-height: var(--s-11);
    padding: var(--s-3) var(--s-2);
    color: var(--n-500);
    font-family: var(--font-cond);
    font-size: var(--fs-micro);
    font-weight: 600;
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    text-decoration: none;
  }

  .ds-nav-inferior__item[aria-current="page"] { color: var(--accent); }

  /* El acento no basta: el color solo no es una señal accesible (§5). */
  .ds-nav-inferior__item[aria-current="page"]::before {
    content: "";
    position: absolute;
    top: 0;
    width: var(--s-9);
    height: var(--bd-w-signal);
    background: var(--accent);
  }

  .ds-nav-inferior__item { position: relative; }

  .ds-nav-inferior__i { font-size: var(--fs-lg); }

  .ds-nav-inferior__n {
    position: absolute;
    top: var(--s-2);
    left: 50%;
    min-width: var(--s-6);
    margin-left: var(--s-3);
    padding: 0 var(--s-2);
    border-radius: var(--r-pill);
    background: var(--risk);
    color: var(--n-0);
    font-size: var(--fs-micro);
    line-height: var(--s-6);
    text-align: center;
  }

  /* Sin esto la barra tapa la última fila de la pantalla, que suele ser la
     acción. El alto se declara aquí y no en cada app. */
  .ds-app--con-nav-inferior {
    padding-bottom: calc(var(--s-11) + var(--s-6) + env(safe-area-inset-bottom, 0px));
  }
}

/* =========================================================================
   26. DIRECTORIO — la ficha de un contacto
   Una rejilla de personas: nombre, cómo llamarles y una nota. No es una
   tabla y no debe serlo, y la diferencia es de USO: una tabla se recorre de
   arriba abajo comparando la misma columna —cuánto, cuándo, quién— y aquí no
   se compara nada. Se BUSCA a alguien concreto, se le mira el teléfono y se
   sale. Para eso, cinco columnas con cuatro celdas medio vacías cada una es
   la peor forma posible: obliga a leer en horizontal para reunir tres datos
   que van juntos.

   Nació en la Libreta de Recepción (2026-08-17), que es la agenda de los
   teléfonos que NO son de pacientes —doctores que derivan, proveedores,
   mensajería—. Está aquí y no en una hoja de app porque «un directorio de
   personas» lo pide cualquiera: el equipo en OdontoHR, los colaboradores en
   DentalPartners, los socios del Club.

   Tres decisiones, y las tres son de fondo:

   1. **La inicial en un disco, no una foto.** Aquí no hay fotos y no las va a
      haber; un hueco gris con un icono genérico repetido veinte veces es
      ruido, y la inicial sí discrimina —le da a cada ficha una marca distinta
      con la que el ojo vuelve al sitio— sin inventarse un dato.
   2. **El teléfono es el contenido, no un metadato.** Es a lo que se viene:
      va en su renglón, en numeración tabular para que dos números se comparen
      en vertical, y con `tel:` para que se pueda pulsar.
   3. **Las acciones no compiten.** Editar y quitar van arriba a la derecha,
      en fantasma y con `aria-label`: siempre visibles —revelarlas al pasar el
      ratón deja la ficha sin acciones en una tableta— pero sin peso visual.
   ========================================================================= */

.ds-directorio {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
  gap: var(--s-5);
}

.ds-contacto {
  display: flex;
  align-items: flex-start;
  gap: var(--s-5);
  padding: var(--s-5) var(--s-6);
  border: var(--bd);
  /* `--r-md` y no `--r-lg`: el grande está reservado a modales y hojas
     laterales (tokens.css), y una tarjeta con radio de modal se lee como algo
     que se ha abierto encima. */
  border-radius: var(--r-md);
  background: var(--n-0);
  transition: border-color var(--dur-fast) var(--ease);
}

.ds-contacto:hover { border-color: var(--n-300); }

/* La ficha que se está editando ARRIBA. Con el formulario fuera de la rejilla
   (ver el editor, abajo) ésta es la única señal de a quién se está tocando: un
   anillo de acento, que es lo mismo que dice una fila seleccionada en una
   tabla. No se tiñe el fondo —el disco de la inicial ya es `--accent-bg` y se
   perdería dentro—. */
.ds-contacto--activo {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}

.ds-contacto__avatar {
  flex: none;
  display: grid;
  place-items: center;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--r-pill);
  background: var(--accent-bg);
  color: var(--accent-600);
  font-size: var(--fs-md);
  font-weight: 600;
  line-height: 1;
  text-transform: uppercase;
}

.ds-contacto__cuerpo { min-width: 0; flex: 1; }

.ds-contacto__nombre {
  font-weight: 600;
  color: var(--n-800);
  overflow-wrap: anywhere;
}

/* Categoría y demás rótulos, pegados al nombre. */
.ds-contacto__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3);
  margin-top: var(--s-2);
}

.ds-contacto__tels {
  margin-top: var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.ds-contacto__tel {
  display: inline-flex;
  align-items: center;
  gap: var(--s-4);
  color: var(--n-700);
  font-variant-numeric: tabular-nums;
}

.ds-contacto__tel:hover { color: var(--accent-600); }

.ds-contacto__tel i {
  color: var(--n-400);
  font-size: var(--fs-xs);
}

.ds-contacto__nota {
  margin-top: var(--s-4);
  font-size: var(--fs-xs);
  color: var(--n-500);
  overflow-wrap: anywhere;
}

.ds-contacto__acciones {
  flex: none;
  display: flex;
  gap: var(--s-2);
}

/* ---- EL EDITOR: uno solo, en el mismo sitio siempre ----------------------
   La primera versión editaba EN SU CELDA —el formulario ocupaba la fila
   entera de la rejilla, donde estaba la ficha—. Se usó y falla por tres
   sitios (CEO, 2026-08-17, sobre una libreta de 101 contactos):

   1. **El formulario aparece EN MEDIO de las fichas.** Entre la fila 2 y la 4
      no se lee como un formulario: se lee como una tarjeta rota. Y para
      llegar a él hay que buscarlo, que es justo lo que un editor no puede
      pedir.
   2. **El alta iba a todo lo ancho**: cuatro campos de 350px de una punta a
      otra de un monitor de 1.500. La etiqueta y su campo quedan a dos palmos
      y el ojo vuelve a empezar en cada uno — es el mismo motivo por el que
      el arquetipo E acota en `--form-max`, y aquí no se aplicaba.
   3. **Editar movía el formulario de sitio**: al pulsar el lápiz, la caja en
      la que se iba a escribir aparecía en otra parte de la página.

   Hoy hay UN editor, y está siempre donde estaba: una banda clavada
   (`sticky`) arriba de la región que scrollea, con la caja dentro acotada.
   Pulsar «editar» **no mueve nada** — cambia lo que hay dentro de la caja que
   ya estaba ahí, y la lista le sigue pasando por debajo.

   Dos decisiones que no son cosmética:

   - **Lleva sombra, y es de las poquísimas cosas fuera de un modal que la
     lleva** (tokens.css: la sombra dice que algo FLOTA sobre el documento).
     Aquí eso es literalmente cierto: las fichas pasan por debajo. Sin ella,
     una banda del mismo blanco que la lista se lee como la primera fila.
   - **En el móvil deja de estar clavada.** Debajo de 641px la región no es el
     scrollport —scrollea la página, ver `a-tabla.css`— y un formulario fijo
     de 200px sobre una pantalla de 640 se come un tercio de la lista.
   ------------------------------------------------------------------------ */

.ds-directorio-editor {
  position: sticky;
  top: 0;
  z-index: 2;
  padding: var(--s-5) var(--s-6);
  border-bottom: var(--bd-strong);
  /* El escalón de fondo es lo que separa la banda de la lista (DESIGN.md §1:
     las superficies se distinguen con borde y escalón, no con sombra). `--n-50`
     y no `--n-25`: la lista de fichas es blanca sobre blanco y el medio tono
     de más arriba no llegaba a leerse como otra superficie. */
  background: var(--n-50);
  box-shadow: var(--sh-overlay);
}

.ds-directorio-editor__caja {
  max-width: var(--form-max);
  margin-inline: auto;
}

/* Editando: marco de acento y cabecera teñida. Es el único aviso de que ese
   formulario NO va a crear un contacto nuevo, y el estado no se comunica solo
   por color (DESIGN.md §5): el título dice «Editar» y la cabecera lleva el
   nombre de quien se está editando. */
.ds-directorio-editor--abierto .ds-directorio-editor__caja { border-color: var(--accent); }

.ds-directorio-editor--abierto .ds-panel__head {
  background: var(--accent-bg);
  border-radius: var(--r-md) var(--r-md) 0 0;
}

@media (max-width: 640px) {
  .ds-directorio-editor {
    position: static;
    box-shadow: none;
  }
}
