/* =========================================================================
   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;
  flex-wrap: wrap;
  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);
}

/* UN AVISO NO ES UNA COLUMNA AL LADO DEL BOTÓN: SE LLEVA SU RENGLÓN.
   El pie es una fila, y una fila coloca a su lado todo lo que le eches. En la
   ficha de tarea del doctor eso puso TRES columnas en un móvil —«queda 1
   presupuesto por validar», el botón «Pedir nuevo presupuesto» y «Atención al
   paciente lo creará…»— cada una de un tercio de 390px. El botón, que es
   `white-space: nowrap`, se sale de su caja antes que ceder ancho.

   Es EXACTAMENTE el defecto que ya estaba resuelto para la fila de acciones de
   un bloque (`.ds-b__accion` en `apps/coordinador.css`), con la misma cura y
   por el mismo motivo, y aquí faltaba: el pie del panel es el otro sitio donde
   el sistema pone un motivo escrito junto a un botón, porque §6 no admite que
   una información viva solo en un `title` —en un móvil no hay hover, así que
   un tooltip informa igual que el silencio—.

   `flex: 1 0 100%` en las dos anchuras y no bajo una media query: por encima de
   640px los botones son de ancho automático, así que dar el renglón entero al
   aviso no cambia nada mientras quepa, y por debajo es lo único que evita las
   columnas. `margin: 0` porque el hueco lo pone el `gap` del pie. */
.ds-panel__foot > .ds-help { flex: 1 0 100%; margin: 0; }

/* 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.
   ========================================================================= */

/* La separación entre la etiqueta y su control **no es estética**: el anillo de
   foco se pinta 4px por fuera de la caja (`--focus`, ver base.css §5), así que
   con los 6px que había aquí antes el anillo pasaba a 2px del rótulo y lo
   rozaba. Con 8px queda a 4px, la misma holgura que el sistema deja en
   cualquier otro sitio. Medido el 2026-08-18 en el card del Coordinador. */
.ds-field {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.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;
}

/* La ayuda de un campo es TEXTO, así que tiene medida: en un panel a sangre
   —el del plan de tratamiento mide 1.304px en un portátil— una sola frase se
   estiraba en un renglón de punta a punta de la tarjeta y dejaba de leerse como
   la nota de su campo. El tope no se nota en un formulario en columna, donde la
   columna ya es más estrecha que esto. */
.ds-field__help {
  max-width: 62ch;
  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; }

/* UNA FILA DE PANELES NO ES UNA FILA DE CAMPOS. El `minmax(220px, …)` de
   arriba está medido para un `<input>` con su etiqueta encima. Cuando lo que
   se reparte son PANELES enteros —con su nota explicativa dentro— la misma
   rejilla mete cuatro columnas de 290px en una columna de 1.240 y la nota se
   lee en tiras de tres palabras: medido en `/configuracion/datalytics/` el
   2026-08-27, donde «¿Qué es ETL Hub?» ocupaba trece renglones.

   Un panel pide ancho de LECTURA, no de campo: dos por fila como mucho, y uno
   solo en cuanto la pantalla no da para los dos. 28rem es el mínimo con el que
   dos caben en la columna ancha de Configuración (1.240px) y ninguno baja del
   ancho al que su texto sigue siendo un párrafo. */
.ds-formrow:has(> .ds-panel) {
  grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
}

/* 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); }

/* ⚠️ EL TRIÁNGULO NO ERA UN SITIO DONDE SE PUDIERA PULSAR, Y ESO ES LO QUE EL
   CEO REPORTÓ COMO «LOS TRIÁNGULOS NO FUNCIONAN» (2026-08-21).

   El `<th>` declaraba `cursor: pointer` —o sea, prometía que la celda entera
   obedece— pero el único elemento navegable era el `<a>` del rótulo, y el
   indicador lo pintaba un `::after` **del `<th>`**: fuera del enlace por
   construcción. En las columnas de ancho justo era peor todavía, porque ahí el
   `margin-right` negativo lo empujaba al relleno de la celda, separado del
   texto: se veía una flecha, se pulsaba la flecha y no pasaba nada.

   Se arregla moviendo las dos cosas —el relleno de la celda y el indicador— al
   propio enlace: el `<a>` pasa a ser un bloque que ocupa el `<th>` entero
   (márgenes negativos que se comen el relleno de la celda y lo vuelven a poner
   como suyo), así que cualquier píxel de la cabecera, triángulos incluidos, es
   el enlace. No hace falta tocar `sort_th`: se selecciona por
   `th[aria-sort] > a`, y así las 108 columnas del portal lo heredan de golpe. */
/* El relleno se lo queda el ENLACE, no la celda: es la única forma de que el
   enlace llegue hasta el borde. Y `height: 100%` es la otra mitad — sin él el
   enlace mide lo que mide SU rótulo, y en una fila donde otra cabecera ocupa
   tres renglones («EN JUEGO €» partido) las cortas dejaban una franja muerta
   debajo del texto: pulsabas dentro de la celda, con la mano de «esto se
   pulsa» puesta, y no pasaba nada. La franja era el defecto original en
   pequeño. */
.ds-table thead th[aria-sort]:has(> a) {
  padding: 0;
  /* `height: 1px` es un MÍNIMO, no una altura: la celda sigue creciendo con su
     contenido. Está aquí para que sea una altura DECLARADA, que es lo que hace
     que el `height: 100%` del enlace resuelva contra la celda en vez de caer a
     `auto`. Sin ella el enlace vuelve a medir lo que mide su rótulo. */
  height: 1px;
}

.ds-table thead th[aria-sort] > a {
  position: relative;
  display: flex;
  align-items: center;
  height: 100%;
  /* Por la derecha, 14px de más: es el hueco del indicador (8 de triángulo +
     6 de aire). Reservarlo en el RELLENO y pintar el par en `absolute` es lo
     que impide que el indicador cuente como una palabra más del rótulo y se
     vaya él solo a un tercer renglón en una columna estrecha. */
  padding: var(--s-3) calc(var(--cell-pad-x) + 14px) var(--s-3) var(--cell-pad-x);
  color: inherit;
  text-decoration: none;
}

/* ⚠️ SON DOS TRIÁNGULOS, NO UNO — petición literal del CEO el mismo día: «dos
   triángulos, uno sobre otro (un rombo con espacio en medio), así puedes
   ordenar como quieras».

   Con un solo triángulo la cabecera enseñaba el orden VIGENTE y callaba el
   otro sentido, así que para saber si «Espera (máx.)» se podía poner al revés
   había que pulsarla y ver qué pasaba. Con el par, los dos sentidos están
   dibujados desde el primer pintado y el color dice cuál manda: gris los dos
   mientras la columna no ordena, y el que ordena en acento.

   Es la misma razón por la que la rama `none` ya estaba vestida —una columna
   ordenable tiene que distinguirse de una que no lo es sin pasar el ratón por
   encima—, llevada hasta el final. */
.ds-table thead th[aria-sort] > a::before,
.ds-table thead th[aria-sort] > a::after {
  content: "";
  position: absolute;
  top: 50%;
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
}

/* El hueco del medio son los 4px que separan el `-7` del `+2`: sin él los dos
   triángulos se tocan y el par se lee como un rombo macizo, que es otra cosa. */
.ds-table thead th[aria-sort] > a::before {
  margin-top: -7px;
  border-bottom: 5px solid var(--n-300);
}

.ds-table thead th[aria-sort] > a::after {
  margin-top: 2px;
  border-top: 5px solid var(--n-300);
}

/* De gris a ACENTO en cuanto la columna ordena, y solo el sentido vigente.
   El `aria-sort` ya lo decía para el lector de pantalla; esto lo dice para el
   ojo. No es color como único canal: la posición del triángulo teñido —arriba
   o abajo— es el segundo. */
.ds-table thead th[aria-sort="ascending"]  > a::before { border-bottom-color: var(--accent); }
.ds-table thead th[aria-sort="descending"] > a::after  { border-top-color: var(--accent); }

/* Columna de texto: el par va DETRÁS del rótulo, en el hueco que le reserva el
   relleno de aquí arriba. */
.ds-table thead th[aria-sort] > a::before,
.ds-table thead th[aria-sort] > a::after {
  right: var(--cell-pad-x);
}

/* ⚠️ COLUMNA NUMÉRICA: EL PAR VA DELANTE, y esa es la otra mitad de lo que el
   CEO pidió («justifica a la derecha el texto Espera (máx.)»).

   `.ds-num` alinea el rótulo a la derecha para que caiga justo encima de sus
   cifras. Reservando el hueco del indicador por la derecha, ese rótulo quedaba
   14px por dentro y **la cabecera dejaba de caer sobre la columna que nombra**
   —el mismo defecto que `.ds-cell--acciones` documenta unas líneas más abajo—.
   Poniendo el par a la izquierda, el borde derecho del rótulo y el de los
   números vuelven a ser el mismo. */
.ds-table thead th.ds-num[aria-sort] > a {
  /* `justify-content` y no `text-align`: el rótulo es un item de flex y al
     `text-align` heredado de `.ds-num` no le llega mientras el item mida lo
     que mide su contenido. Los dos hacen falta —este coloca la CAJA, aquel
     alinea los renglones cuando el rótulo se parte en dos. */
  justify-content: flex-end;
  padding-right: var(--cell-pad-x);
  padding-left: calc(var(--cell-pad-x) + 14px);
}

.ds-table thead th.ds-num[aria-sort] > a::before,
.ds-table thead th.ds-num[aria-sort] > a::after {
  right: auto;
  left: var(--cell-pad-x);
}

/* ⚠️ LA CABECERA QUE NO TIENE ENLACE DENTRO — y no es un caso raro: es el
   `initTableSorting()` de `comercial.js`, que ordena EN CLIENTE sobre
   `table.sortable-table` y pone el `aria-sort` en un `<th data-sort>` pelado,
   con el manejador de clic en el propio `<th>`. Ahí el enlace no existe y no
   debe existir: el orden no cambia de URL.

   Así que ese caso pinta el par sobre el `<th>`, que es donde se pulsa.
   `:not(:has(> a))` es lo que separa a los dos sin marcar nada en la plantilla:
   una clase que hubiera que acordarse de poner dejaría veinte tablas sin
   indicador, que es de donde venimos. */
/* ⚠️ `sticky` Y NO `relative`, aunque aquí solo haga falta ser un bloque
   contenedor para el par de triángulos de abajo. `relative` lo era, y de paso
   PISABA el `position: sticky` con el que nace `.ds-table thead th` —este
   selector pesa más—: o sea que la fila de títulos dejaba de clavarse
   exactamente en las tablas que se pueden ordenar, que son las largas. `sticky`
   contiene a los absolutos igual de bien y conserva el `top: 0` y el `z-index`
   que la cabecera ya declara. Encontrado el 2026-09-01 con el CEO mirando
   Stock → Bajo mínimo: 60 filas y ningún título a partir de la séptima. */
.ds-table th[aria-sort]:not(:has(> a)) {
  position: sticky;
  padding-right: calc(var(--cell-pad-x) + 14px);
}

.ds-table th[aria-sort]:not(:has(> a))::before,
.ds-table th[aria-sort]:not(:has(> a))::after {
  content: "";
  position: absolute;
  top: 50%;
  right: var(--cell-pad-x);
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
}

.ds-table th[aria-sort]:not(:has(> a))::before {
  margin-top: -7px;
  border-bottom: 5px solid var(--n-300);
}

.ds-table th[aria-sort]:not(:has(> a))::after {
  margin-top: 2px;
  border-top: 5px solid var(--n-300);
}

.ds-table th[aria-sort="ascending"]:not(:has(> a))::before { border-bottom-color: var(--accent); }
.ds-table th[aria-sort="descending"]:not(:has(> a))::after { border-top-color: var(--accent); }

/* Celda de acciones por fila: se reserva el ancho siempre, para que la fila
   no dé un salto lateral al aparecer los botones.

   Vale también para el `<th>`, y no es cosmética: sin él el rótulo de la
   columna se queda a la izquierda mientras sus botones están a la derecha, y
   una cabecera que no cae encima de lo que nombra se lee como desorden aunque
   los píxeles estén exactamente donde tienen que estar. Salió midiendo el
   «no sale ordenado» del buzón de imagen (2026-08-18): las cuatro columnas
   alineaban al píxel y aun así la tabla parecía descuadrada. */
.ds-table :is(th, 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;
  /* Y SE PEGAN AL BORDE DERECHO. La celda ya declaraba `text-align: right`
     arriba, pero eso no coloca nada: dentro hay un `.ds-cluster`, que es un
     contenedor flex, y a un flex lo alinea `justify-content` — el `text-align`
     del padre no le llega. Así que la declaración de intención estaba escrita
     y no se cumplía.

     Qué se veía, y lo reportó el CEO mirando la bandeja de recepción
     (2026-08-18): la columna de acciones mide lo que mide la fila MÁS ancha
     —`width: 1%` la encoge al contenido, pero una columna de tabla es una sola
     anchura para todas—, así que la fila que además lleva el icono de
     conversación fija el ancho de la columna, y las que no lo llevan dejaban su
     botón azul **alineado a la izquierda**, medio botón por delante de los
     demás. En una lista de siete filas donde el icono va y viene, la acción
     primaria bailaba de sitio en cada renglón.

     Se alinea por la DERECHA y no por la izquierda porque la acción primaria es
     el ancla: es la que se busca, la que se pulsa y la única que está en todas
     las filas. Alinear por la izquierda dejaría cuadrado lo opcional y bailando
     lo importante. Es además lo que ya hace el mismo componente en móvil
     (`justify-content: flex-end`, §5.a), así que esto no estrena criterio:
     lo iguala. */
  justify-content: flex-end;
}

/* 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. */

/* LA COLUMNA DE ANCHO PREDECIBLE MIDE SU CONTENIDO, NO LA SOBRA DE LA TABLA.
   Es la misma mecánica que `.ds-cell--acciones` de aquí arriba (`width: 1%` es
   «encógete a lo tuyo» en el reparto automático), pero para datos: una fecha,
   un importe, un contador de días.

   Por qué hace falta, y es medible (cola comercial, 2026-08-21, avisado por el
   CEO): en el reparto automático una columna nunca baja de su **contenido
   mínimo**, y en estas columnas lo más ancho no es el dato — es el RÓTULO, que
   `thead th` pinta en versalitas y `white-space: nowrap`. «Último movimiento»
   reservaba 152 px para pintar «16/07/2026», y «Espera (máx.)» 118 px para tres
   dígitos: 371 px de una tabla de 1.248 —el 30 %— para tres columnas cuyo dato
   más largo mide un tercio de eso. Lo que sobra se lo quitan las columnas que
   sí varían, y con ellas estranguladas la fila se parte en cuatro renglones:
   «Pendiente de aceptación» salía a palabra por línea. Medido a 1.280 px, con
   control negativo: las tres columnas pasan de 371 px a 262 —incluido el hueco
   que se le reserva a la flecha de orden, ver abajo—, la de «por qué» de 310 a
   405 y la fila alta de la lista de 109 px a 51.

   Por eso el rótulo SÍ puede partirse en dos renglones y el dato no: la
   cabecera es una línea más una sola vez, y la fila se repite cuarenta veces.
   Ese es todo el intercambio.

   ⚠️ **NO ES OPT-IN, Y ESA FUE LA CORRECCIÓN DEL CEO EL MISMO DÍA.** El defecto
   no es de la cola: lo tiene toda tabla con una columna de ancho fijo y rótulo
   largo, y son 354 plantillas. Una clase que hay que acordarse de poner arregla
   la pantalla que se está mirando y deja rotas las otras 353 — que es
   exactamente cómo se llegó aquí.

   Así que la regla la hereda `.ds-num`, que YA marca en todo el portal
   —2.654 celdas— lo mismo que esta clase declara: derecha, tabular, sin
   envolver, o sea **ancho predecible**. `ds-col--justa` se queda para lo que el
   sistema no puede saber: la fecha, el código, el «hace N días» — columnas
   igual de predecibles que no son un número.

   Lo que la automática NO hace es romper la tabla que va sobrada: `width: 1%`
   nunca baja del contenido mínimo, así que una columna numérica no se estrecha
   por debajo de su cifra más larga, y si TODAS las columnas son numéricas el
   reparto vuelve a ser el de antes porque no queda ninguna a la que dar la
   sobra. */
.ds-table :is(th, td):is(.ds-num, .ds-col--justa) { width: 1%; }

/* `.ds-num` ya lo trae de su propia regla; esto es para las demás. */
.ds-table :is(th, td).ds-col--justa { white-space: nowrap; }

.ds-table thead th:is(.ds-num, .ds-col--justa) { white-space: normal; }

/* EL INDICADOR DE ORDEN NO ES UNA PALABRA MÁS DEL RÓTULO. En cuanto la
   cabecera puede partirse, un triángulo que fuera contenido de la línea sería
   lo último de ella y en una columna estrecha no cabría: se iría él solo a un
   tercer renglón, debajo de un rótulo de dos. Un indicador de orden separado
   de la columna que ordena no se lee como su indicador.

   Por eso el par de triángulos de §5 va en `position: absolute` dentro del
   enlace, con su hueco reservado en el relleno: no cuenta para el salto de
   línea y queda centrado en la celda, que es donde el ojo lo busca cuando la
   fila de cabeceras mezcla rótulos de una y de dos líneas.

   Aquí ya no queda nada que declarar: el relleno de esas columnas lo pone la
   regla del enlace, y por eso el `padding-right` que vivía en este sitio se
   fue con ella. */

/* ------------------------------------------------------------------------
   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);
  }

  /* Y por lo mismo el de las columnas de ancho predecible: en tarjeta no hay
     columnas que repartir —cada celda es un renglón con su rótulo delante—,
     así que un `width: 1%` aquí dejaría el dato aplastado contra el borde
     izquierdo. Medido con control negativo: la fecha en 4 px. */
  .ds-table--responsive tbody td:is(.ds-num, .ds-col--justa) { width: 100%; }

  /* 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.a-ter MÓVIL — LA TARJETA QUE SE RELLENA  (`ds-table--editable`)

   La tercera variante, y existe porque las dos anteriores dan por supuesto que
   la tarjeta se LEE. Cuando la fila se ESCRIBE —una rejilla de `ds-cellinput`,
   que es lo que Stock estrenó y hoy usan también las líneas de «Pedir
   presupuesto» del Coordinador— la tarjeta de §5.a se comporta al revés de lo
   que hace falta:

     · El rótulo se lleva el **38 %** del ancho, así que el campo se queda con
       lo que sobra… y encima `.ds-cellinput` tiene `max-width: 5rem`, pensado
       para una COLUMNA de escritorio. Medido a 390 px en el modal del
       Coordinador (arnés `files/coordinador_tarea_detalle/medir_modal.py
       --lineas=5`): **campos de 80 px** en una tarjeta de 342.
     · Cada celda es un renglón, y un renglón con un control dentro mide 44 px
       (§6, objetivo táctil). Seis celdas más la de acciones = **368 px por
       línea** y **1.903 px las cinco**: media pantalla por tratamiento.

   Lo que cambia, y son tres cosas:

     1. **El campo se lleva el ancho que sobra** — se levanta el `max-width` de
        la celda de escritorio y el rótulo pasa a columna fija y estrecha.
     2. **La acción sube al primer renglón**, con el mismo mecanismo del corte
        explícito de §5.a-bis (`order` + un pseudo-elemento de base 100 %). Un
        renglón entero para una ✕ es el gasto más fácil de quitar.
     3. **`ds-col--mitad`**: dos celdas cortas y seguidas comparten renglón. Con
        el rótulo EN LÍNEA —no en columna— el campo sale más ancho que antes
        aunque la celda mida la mitad, así que esto no es un recorte: es un
        renglón menos y un campo mayor a la vez.

   Medido en el mismo sitio, a 390 × 664 con 5 líneas: la línea pasa de **368 a
   230 px**, las cinco de **1.903 a 1.171**, el campo de **80 a 119–238** y los
   nombres partidos en dos renglones de **5 de 5 a 1 de 5** (el que queda tiene
   51 caracteres: ese SÍ tiene que envolver). El campo sigue midiendo 44 px de
   alto, que es el objetivo táctil de §6 — la densidad no sale de encoger lo que
   se pulsa.

   NO toca el escritorio: todo va dentro de la `@media` de tarjeta. Ahí la fila
   sigue siendo una rejilla, que es lo correcto cuando hay ancho. Comprobado a
   1440 × 900: la línea se queda en 37 px y los campos suben de 58–75 a 63–80.

   ⚠️ Es **opt-in**: se declara junto a `--responsive` y ninguna de las ~230
   pantallas que ya la usan cambia. Lo vigila
   `coordinador/tests/test_modal_solicitud_compacto.py`, que falla si algún
   selector de este bloque deja de empezar por `.ds-table--editable` o si alguna
   regla se sale de la `@media`.

   ⚠️ 2026-08-19 — LOS CAMPOS EMPIEZAN TODOS EN LA MISMA VERTICAL, Y ESA
   VERTICAL LA MARCA EL RÓTULO MÁS LARGO.

   El CEO, con la ventana abierta en su móvil: *«el textbox de uds se alinearía
   a la izquierda con el de dientes y nota, en la posición que marque la palabra
   DIENTES como tope por la izquierda»*. Lo que tenía delante eran **tres
   arranques distintos** en la misma tarjeta: «Uds» a 58 px porque su rótulo va
   en línea y es corto, «Dientes» y «Nota» a 124 porque el suyo va en columna.
   Un borde izquierdo en escalera en un formulario de cinco renglones.

   El mecanismo es **una rejilla de cuatro columnas en la FILA**, no un ancho
   por celda:

     `max-content | minmax(0,1fr) | max-content | minmax(0,1fr)`

   La primera columna es el carril de los rótulos, y `max-content` la hace medir
   **el más ancho de los que caen dentro**. Quién cae dentro lo decide el
   marcado: las celdas con campo (`ds-col--campo`) se subrejillan
   (`grid-template-columns: subgrid`), así que su rótulo entra en esa columna y
   la sostiene; las de los renglones 1 y 2 ocupan la fila entera y **no**
   contribuyen —un ítem que cruza una pista flexible no cuenta para el tamaño
   intrínseco de las que cruza (CSS Grid §12.5)—, que es exactamente por lo que
   «TRATAMIENTO», más largo que «DIENTES», no mueve la línea.

   Por eso no hay ni un píxel medido a ojo: si mañana un rótulo se alarga, se
   traduce o se añade un campo, la columna se recalcula sola y los cuatro campos
   siguen alineados. El rótulo de una celda ya no manda sobre su propio campo;
   manda el carril.

   Las dos columnas flexibles son iguales entre sí, y de ahí sale lo otro que
   pidió el CEO: los dos campos del renglón 3 —«Uds» y «Dto %», media fila cada
   uno (`ds-col--mitad`)— **se reparten uniformes** lo que queda desde la línea
   hasta el borde derecho, con su rótulo en medio. Los campos de renglón entero
   se estiran hasta el borde (`grid-column: 2 / -1`), o sea que **crecen hacia
   la izquierda** hasta tocar la línea: se ensanchan, no se estrechan.

   La rejilla sustituye al `flex-wrap`, y con ella se va el pseudo-elemento que
   cortaba la línea: cada celda declara **cuántas columnas ocupa**, así que el
   renglón en el que cae no depende de dónde deje de caber.
   ------------------------------------------------------------------------ */

@media (max-width: 640px) {
  /* CUATRO COLUMNAS: el carril de los rótulos, el campo, el rótulo de en medio
     y el segundo campo. Las dos flexibles con `minmax(0, …)` a propósito: un
     `<input>` tiene ancho intrínseco propio (~177 px), y con el `auto` que trae
     `1fr` por defecto ese ancho sería el mínimo de la pista y la tarjeta
     desbordaría a lo ancho. */
  .ds-table--editable tbody tr {
    display: grid;
    grid-template-columns: max-content minmax(0, 1fr) max-content minmax(0, 1fr);
    align-items: center;
    column-gap: var(--s-5);
    padding: var(--s-4) var(--s-5);
  }

  /* Un dato por renglón; la separación entre renglones baja a `--s-1` porque
     el alto de la celda ya lo fija el control que lleva dentro. */
  .ds-table--editable tbody td {
    grid-column: span 4;
    order: 2;
    min-width: 0;
    gap: var(--s-4);
    padding: var(--s-1) 0;
  }

  /* El rótulo deja de ser el 38 % y pasa a una columna fija: 38 % de 342 px son
     130 y ningún rótulo de esta tabla los necesita. Es exactamente el ancho que
     se le devuelve al campo. Vale para los renglones que solo se LEEN —código y
     tratamiento—; el de los que se escriben lo fija la rejilla, más abajo. */
  .ds-table--editable tbody td::before { width: 5.5rem; }

  /* RENGLÓN 1 — la identidad de la fila y la acción que la quita, con `order: 0`
     para que la ✕ suba desde el final del marcado. Ya no hace falta cortar la
     línea con un pseudo-elemento: la identidad ocupa tres columnas y la acción
     la cuarta, así que el renglón se cierra por sí solo. */
  .ds-table--editable tbody td:first-child {
    grid-column: span 3;
    order: 0;
  }

  .ds-table--editable tbody td.ds-cell--acciones {
    grid-column: span 1;
    width: auto;
    order: 0;
    padding: 0;
  }

  /* RENGLONES 3, 4 y 5 — LA LÍNEA ÚNICA. La celda se subrejilla en vez de
     maquetar por su cuenta: hereda las columnas de la fila, así que su rótulo
     cae en el carril común y su campo empieza donde empiezan todos. Es la
     ÚNICA declaración que fija la línea, y no dice ningún píxel. */
  .ds-table--editable tbody td.ds-col--campo {
    display: grid;
    grid-template-columns: subgrid;
    align-items: center;
  }

  /* El rótulo mide lo que mide su palabra; quien iguala es la columna. */
  .ds-table--editable tbody td.ds-col--campo::before { width: auto; }

  /* Dos celdas cortas y seguidas, un solo renglón: media rejilla cada una
     —rótulo y campo—, así que los dos campos salen del mismo tamaño. */
  .ds-table--editable tbody td.ds-col--mitad { grid-column: span 2; }

  /* El campo de un renglón entero llega hasta el borde derecho: crece hacia la
     izquierda hasta la línea, no al revés. */
  .ds-table--editable tbody td.ds-col--campo:not(.ds-col--mitad) .ds-cellinput {
    grid-column: 2 / -1;
  }

  /* EL CAMPO SE LLEVA LO QUE QUEDE. `max-width: 5rem` es de la celda de
     escritorio (§5.b), donde la columna manda; aquí manda la tarjeta. */
  .ds-table--editable tbody .ds-cellinput { max-width: none; }

  /* Un número se sigue leyendo por la derecha; un texto libre, no. En columna
     estrecha daba igual —todo cabía justo—, pero en un campo de 240 px un
     «1.6, 2.6» pegado al borde derecho se lee como si estuviera mal puesto. */
  .ds-table--editable tbody .ds-cellinput:not([type="number"]) {
    text-align: left;
  }

  /* LA CEBRA SE PINTA EN LA FILA, NO EN LA CELDA. `--zebra` tiñe los `td`, y
     eso vale mientras la celda ocupa el renglón entero; aquí la fila es un
     flex con hueco entre columnas y relleno propio, así que teñir las celdas
     dejaba la tarjeta a medio pintar —franjas blancas entre «Uds» y «Dto %» y
     alrededor del borde—. Es el mismo movimiento que §5.a ya hace con la fila
     seleccionada. */
  .ds-table--editable.ds-table--zebra tbody tr:nth-child(even) {
    background: var(--n-25);
  }

  .ds-table--editable.ds-table--zebra tbody tr:nth-child(even) td {
    background: none;
  }
}

/* =========================================================================
   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); }

/* Y el tercer estado, que faltaba: EDITADO Y TODAVÍA SIN GUARDAR. En una
   rejilla que guarda al salir del campo, es la diferencia entre «ya está» y
   «esto se pierde si te vas». Lo tenía Stock resuelto con un `#eab308` escrito
   a mano dentro del JavaScript —el amarillo del sistema anterior, que el tema
   no alcanza—; es un estado del componente, así que su sitio es este. */
.ds-cellinput--pendiente { border-color: var(--warn); background: var(--warn-bg); }

/* LA CELDA QUE DECIDE ALGO: un control y el botón que lo confirma, en la misma
   línea y sin excepciones.

   No es un `.ds-cluster`. El cluster ENVUELVE —`flex-wrap: wrap`, que es lo
   correcto en una barra de acciones de pantalla— y `.ds-input`/`.ds-select`
   miden el 100 %, así que dentro de una celda el control se come el renglón
   entero y el botón cae debajo: medido en el buzón de imagen del Coordinador
   el 2026-08-18, **9 de 9 filas** con el ✓ en el renglón de abajo y la fila a
   77px en vez de 37. El CEO lo describió como «el botón salta».

   Rejilla y no flex a propósito: `minmax(0, 1fr)` deja que el control se
   ENCOJA por debajo de su tamaño intrínseco, que es lo que hace que un
   desplegable con nombres largos no reviente la columna ni saque la tabla a
   scroll horizontal. Un flex con `width: 100%` no se encoge: desborda. */
.ds-cellform {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--s-3);
}

/* =========================================================================
   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;
}

/* -------------------------------------------------------------------------
   6.b-bis BARRA APILADA — «en qué se va esto», no «cuánto llevo»
   Añadida el 2026-08-18 con la pantalla de capacidad y reservas de Cortex,
   que es su primer caso real: un turno se reparte entre primeras visitas,
   revisiones, urgencias y producción, y las cuatro partes suman el total.

   NO es un caso de `.ds-progress` a secas: aquella contesta «cuánto de esto
   está hecho» con UN relleno. Esta contesta «de qué se compone», que es una
   pregunta distinta y hasta hoy no la tenía nadie en el núcleo — la había
   reinventado `cortex.css` como `.cm-sim-bar` para el simulador del cuadro
   de mando, que es exactamente el aviso de DESIGN.md §2: si una hoja de app
   escribe un componente, al núcleo le falta.

   Los tramos usan las RANURAS DE SERIE (`--viz-1..5`) y no los semánticos:
   «primeras visitas» no es un veredicto, es una categoría, y los cinco
   semánticos están reservados (DESIGN.md §5). El orden de las ranuras está
   validado por pares adyacentes y no se reordena.

   ⚠️ El color NO comunica solo: la leyenda lleva la palabra y los minutos van
   escritos en la tabla de al lado. La barra es el resumen visual, nunca el
   dato. La anchura de cada tramo llega por `data-pct` y la pinta
   `static/design/nucleo.js`, igual que en la barra simple — el contrato
   prohíbe `style=""` en la plantilla y el porcentaje es un DATO.
   ------------------------------------------------------------------------- */

.ds-progress--apilada .ds-progress__track { display: flex; }

.ds-progress--apilada .ds-progress__fill {
  flex: none;
  border-radius: 0;
}

/* El color de una serie, aplicable al tramo Y a su punto de leyenda: son la
   misma serie, así que es una sola declaración y no pueden divergir. */
.ds-serie--1 { background: var(--viz-1); }
.ds-serie--2 { background: var(--viz-2); }
.ds-serie--3 { background: var(--viz-3); }
.ds-serie--4 { background: var(--viz-4); }
.ds-serie--5 { background: var(--viz-5); }

/* El tramo que es «lo que queda», no una serie: gris, porque no compite con
   las categorías que sí se han decidido. */
.ds-serie--resto { background: var(--n-300); }

.ds-progress__leyenda {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-5);
  font-size: var(--fs-xs);
  color: var(--n-500);
}

.ds-progress__clave {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-variant-numeric: tabular-nums;
}

.ds-progress__punto {
  width: var(--s-4);
  height: var(--s-4);
  border-radius: var(--r-sm);
  flex: none;
}

/* =========================================================================
   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 `<form>` DENTRO DEL CAMPO NO ES UNA CAJA TRANSPARENTE, así que lo que
   sobra no empuja: SE SALE. El campo mide 180px y no crece; el formulario de
   dentro es un flex cuyos hijos no bajan de su contenido (`min-width: auto`),
   de modo que un `<input>` de veinte caracteres más el botón «Buscar» miden
   260px y los ochenta que sobran se pintan ENCIMA del campo siguiente. Como la
   barra pinta en orden de documento, el que queda debajo es el botón:
   invisible, y sin poder pulsarse. El CEO lo vio en Stock → Pedidos el
   2026-09-01 —el buscador sin su botón, tapado por el `<select>` de
   proveedor—, y estaba igual en doce pantallas más de la misma app.

   Esto solo CONTIENE el daño: envolver es feo, tapar un botón es un fallo. La
   forma correcta es que el FORMULARIO SEA LA BARRA —`<form class="ds-filters">`
   con un `.ds-filters__field` por control—, y entonces el buscador pide su
   ancho con `--ancho` y cada filtro se queda dentro de su caja. Referencia:
   `templates/stock/inventario_almacen.html`. */
.ds-filters__field > form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-5);
}

/* 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.

   ⚠️ ES UN HIJO DE `.ds-filters`, NUNCA UNA SEGUNDA CLASE EN LA MISMA ETIQUETA.
   Planificación de Comercial la llevaba como `class="ds-filters
   ds-filters__nota"` y se comía media pantalla: fuera de la barra, el elemento
   es hijo directo de `.ds-a__main`, que es `flex-direction: column`, y ahí el
   `flex: 1 1 260px` de abajo mide ALTO — 260px de base más `flex-grow: 1`
   repartiéndose el hueco libre con `.ds-a__body`. Un `flex` solo significa lo
   que crees dentro del contenedor para el que se escribió. */
.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);
}

/* =========================================================================
   «POR QUÉ» — el motivo que encabeza una fila de worklist, y los demás
   nombrados debajo. Lo pidió la cola comercial (2026-08-20) y la razón es
   medible: la celda ENUMERABA los motivos y sus fichas —2.016 personas tienen
   más de una ficha y la peor tiene doce— y con doce revisiones vencidas
   escribía los doce códigos de área en un renglón corrido, de los que ninguno
   sirve para decidir si hay que llamar. La fila resume y el acordeón detalla.

   Es una REJILLA de dos columnas y no una lista suelta a propósito: los
   rótulos empiezan en la misma vertical **también entre filas distintas**, y
   eso es lo que permite barrer la columna en vertical buscando un motivo sin
   leer nada más. Con anchos que dependen del texto —píldoras, por ejemplo— esa
   lectura se pierde.

   El color va SOLO en el icono, y solo en el motivo filtrado: cuarenta filas
   con dos o tres píldoras son ochenta manchas compitiendo entre sí.
   ========================================================================= */
.ds-porque {
  display: grid;
  grid-template-columns: minmax(0, 14rem) minmax(0, 1fr);
  gap: var(--s-2) var(--s-5);
  align-items: baseline;
}

/* `display: contents` disuelve la línea en la rejilla del padre: sus dos hijos
   pasan a ser celdas de la misma fila. Sin esto haría falta `subgrid`, que no
   sirve aquí porque la celda no comparte rejilla con las de las otras filas. */
.ds-porque__linea { display: contents; }

.ds-porque__rotulo {
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
  font-weight: 600;
}

.ds-porque__i { width: 1.1rem; text-align: center; color: var(--n-500); flex: none; }

/* El motivo por el que esta persona ha entrado en una cola FILTRADA. Color más
   palabra: el rótulo lleva al lado un `ds-sr-only` que lo dice, porque el color
   solo no informa (DESIGN.md §5). */
.ds-porque__i--filtrado { color: var(--accent); }

.ds-porque__obj { color: var(--n-700); font-size: var(--fs-sm); }

/* Cuántas fichas trae ese motivo: «×12». Tabular para que los números de filas
   consecutivas se alineen. */
.ds-porque__n { font-variant-numeric: tabular-nums; }

/* «Creado 11/08/2026 — Antigüedad: 36 días»: de cuándo es el presupuesto que la
   fila enseña. Tercera línea de la celda del PACIENTE, que es la única con
   hueco horizontal libre — el porqué está en `_cola_creado.html`.

   `block` porque es un renglón propio, y `nowrap` porque tiene que ser UNO: el
   coste de esta pantalla no es el ancho, son las 7.969 filas por las que se
   multiplica cada línea de más. Aquí el `nowrap` es seguro y dentro de «Por
   qué» no lo era: son ~22 caracteres en la celda más ancha de la tabla, la
   misma que aguanta «Cristobal Alfonso Lardín Sánchez». */
.ds-fila__creado {
  display: block;
  margin-top: var(--s-2);
  color: var(--n-500);
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* «y 2 más — pendiente de aceptación, revisión vencida». Ocupa la fila entera
   de la rejilla: es un pie de la celda, no una tercera columna. */
.ds-porque__mas {
  grid-column: 1 / -1;
  font-size: var(--fs-xs);
  color: var(--n-600);
}

@media (max-width: 1200px) {
  /* Sin sitio para dos columnas, la línea se apila y el rótulo manda. */
  .ds-porque { grid-template-columns: minmax(0, 1fr); gap: var(--s-1); }
  .ds-porque__obj { padding-left: calc(1.1rem + var(--s-3)); }
}

/* El chip del motivo por el que esta fila ha entrado en una cola filtrada.
   El filtro acota QUIÉN sale, no qué se ve de él (cola comercial, 2026-08-20),
   así que la fila trae todos sus motivos y hace falta decir cuál contesta al
   filtro. Color **más** palabra: el chip lleva un `ds-sr-only` con «(el motivo
   filtrado)», que es lo que exige DESIGN.md §5 — el color solo no informa. */
.ds-chip--match {
  border-color: var(--accent);
  background: var(--accent-bg);
  color: var(--accent-600);
  font-weight: 600;
}

.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; }

/* El título es un `<span>` a propósito —hay notas donde el texto sigue en la
   MISMA línea («La ejecución falló: timeout»)—, así que cuando lo que viene
   detrás es un bloque hay que darle el aire que no trae por ser inline; si no,
   el rótulo se lee pegado al párrafo, como una primera línea en negrita. */
.ds-note__title + :is(p, div, ul, ol) { margin-top: var(--s-2); }

/* =========================================================================
   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.
   ------------------------------------------------------------------------ */

/* -------------------------------------------------------------------------
   DOS COLUMNAS: EL FORMULARIO AL LADO, NO ENCIMA (2026-09-01)

   El editor era una banda clavada ARRIBA y la rejilla iba debajo. Con la
   pantalla ya cargada de capas —cabecera, descripción, pestañas, barra de
   filtros— al llegar a las fichas no quedaba alto: en un portátil se veía
   media fila de contactos y todo lo demás era formulario. Lo reportó el CEO
   con la captura delante: «con la construcción en capas llega un momento que
   no queda espacio para los contactos».

   La forma es un grid de dos columnas dentro de la región que ya scrollea, con
   el formulario **sticky**: la lista se recorre entera y el editor se queda
   donde está. No se anida un segundo scrollport, y no es pereza — anidar uno
   dentro de `.ds-a__body` exige un alto definido que este armazón no cede, y
   es exactamente el taponamiento que documenta `.ds-b__lista-scroll` en
   `b-flujo.css`: la región deja de bajar y el gesto se queda muerto.

   Debajo de 1024px vuelven a apilarse: dos columnas en 900px dejan las fichas
   en 500px y la rejilla ya se auto-ajusta a 20rem.
   ------------------------------------------------------------------------- */

.ds-directorio-cols {
  display: grid;
  gap: var(--s-6);
  align-items: start;
}

@media (min-width: 1024px) {
  .ds-directorio-cols {
    grid-template-columns: minmax(0, 22rem) minmax(0, 1fr);
  }

  /* SIN FORMULARIO NO HAY DOS COLUMNAS. El editor solo se pinta en la libreta
     y cuando se está editando; en la pestaña de pacientes —y en el filtro de
     personal sin nada abierto— el grid seguía reservando sus 22rem y metía
     TODAS las fichas en esa franja, con media pantalla en blanco al lado. Lo
     reportó el CEO con la captura: «cuando muestras pacientes sí que puedes
     mostrar toda la pantalla de cards».

     Se resuelve con `:has()` y no con una clase condicional en la plantilla
     porque quien sabe si hay formulario es el DOM: el marcado ya lo decide una
     vez con su `{% if %}`, y repetirlo en una clase es la segunda copia de la
     misma condición esperando a divergir. `:has()` es técnica ya asentada en
     este proyecto — el armazón cede el alto con `:has(.ds-a)`. */
  .ds-directorio-cols:not(:has(> .ds-directorio-editor)) {
    grid-template-columns: minmax(0, 1fr);
  }

  /* Al lado, el editor deja de ser una banda: ni fondo escalonado, ni sombra,
     ni borde inferior a lo ancho. Es una caja más. */
  .ds-directorio-cols > .ds-directorio-editor {
    position: sticky;
    top: var(--s-5);
    padding: 0;
    border-bottom: 0;
    background: none;
    box-shadow: none;
  }

  .ds-directorio-cols > .ds-directorio-editor .ds-directorio-editor__caja {
    max-width: none;
  }

  /* La cabecera del panel es una fila de flex y en 22rem no cabe: el título,
     el distintivo de a quién se edita y los dos botones se salen de la caja
     por la derecha. Aquí envuelve. `--s-3` de `row-gap` porque al envolver el
     hueco vertical entre dos filas de controles se lee como separación de
     bloques si es el mismo `--s-5` que el horizontal.

     Y `min-width: 0` en el distintivo: un flex item no baja de su contenido
     por defecto, así que un nombre largo empujaba a los botones fuera aunque
     la fila envolviera. */
  .ds-directorio-cols .ds-panel__head {
    flex-wrap: wrap;
    row-gap: var(--s-3);
  }

  .ds-directorio-cols .ds-panel__head > .ds-badge {
    min-width: 0;
  }

  /* `.ds-push` empuja el grupo de botones a la derecha con `margin-left:auto`.
     Al envolver, eso los deja colgando en una fila propia contra el borde: se
     retira para que caigan a la izquierda, alineados con el título. */
  .ds-directorio-cols .ds-panel__head > .ds-push {
    margin-left: 0;
  }
}

.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;
  }
}

/* =========================================================================
   5.g FILA DE DETALLE — «¿por qué está esta persona en la lista?»
   Marcado: `<table data-ds-detalle>` y, DETRÁS de cada fila de resumen, una
   `<tr class="ds-fila-detalle" hidden>` con un único `<td colspan>` dentro.
   El disparador está en `nucleo.js`.

   NO es lo mismo que `data-ds-expandible` (§5.a), y por eso son dos cosas:
   aquel revela las celdas `.ds-col--sec` **solo dentro del media query de
   móvil** —es «enseñar las columnas que la tarjeta esconde»—, y aquí lo que
   hay que enseñar no está en ninguna columna. La cola de Comercial es el caso
   que lo pide: la fila dice «tres motivos distintos» y el detalle dice CUÁLES,
   con su estado y su acción. Ponerlo en columnas costaría tres renglones por
   persona en una tabla que existe justamente para tener una fila por persona.

   Se conserva la convención del otro mecanismo entera —`data-expanded`,
   `aria-expanded`, `tabindex`, e ignorar los clics sobre lo interactivo— para
   no tener un tercer vocabulario de «pulsa aquí» en el producto.

   La fila de detalle es un `<tr>` de la MISMA tabla y no un panel flotante:
   así hereda el ancho de las columnas, se imprime, y el navegador la anuncia
   dentro de la fila a la que pertenece.
   ========================================================================= */

.ds-table[data-ds-detalle] tbody tr:has(+ tr.ds-fila-detalle) {
  cursor: pointer;
}

/* La señal de que hay algo debajo. Sin ella el detalle existe y no lo
   encuentra nadie, que es el fallo mudo que ya costó §5.a. */
.ds-table[data-ds-detalle] tbody tr:has(+ tr.ds-fila-detalle) td:first-child::after {
  content: "▾";
  margin-left: var(--s-3);
  color: var(--n-400);
}

.ds-table[data-ds-detalle]
  tbody tr[data-expanded="true"]:has(+ tr.ds-fila-detalle) td:first-child::after {
  content: "▴";
}

.ds-table[data-ds-detalle] tbody tr[data-expanded="true"] { background: var(--n-25); }

.ds-fila-detalle > td {
  background: var(--n-25);
  padding: var(--s-5) var(--s-6);
  border-bottom: var(--bd);
}

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

.ds-detalle__lista {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.ds-detalle__item {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-4) var(--s-5);
  background: var(--n-0);
  border: var(--bd-strong);
  border-radius: var(--r-md);
}

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

/* El renglón secundario de un item: área, estado, responsable, antigüedad.
   Va en la misma línea mientras quepa y baja entero cuando no. */
.ds-detalle__meta {
  color: var(--n-500);
  font-size: var(--fs-xs);
}

/* Las acciones se van al final de la línea, nunca al principio: lo primero
   que hay que leer es de qué va esto. */
.ds-detalle__acciones {
  margin-left: auto;
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
}

@media (max-width: 640px) {
  /* En modo tarjeta la celda ya es un bloque; lo que sobra es el rótulo
     fantasma que `--responsive` añade con `::before`. Se suprime con
     `data-label=""`, igual que en la columna de acciones. */
  .ds-detalle__acciones { margin-left: 0; width: 100%; }
}

/* El pie del detalle: acciones que son de LA PERSONA y no de una de sus
   fichas — hoy solo el opt-out comercial de la cola única. Separado por un
   filete y alineado a la derecha para que no se lea como una acción más de la
   última ficha de la lista, que es justo el error que costaría caro aquí: lo
   que hay debajo es irreversible. */
.ds-detalle__pie {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--s-3);
  padding-top: var(--s-4);
  border-top: var(--bd);
}

/* =========================================================================
   27. LA CONVERSACIÓN
   Añadido el 2026-08-07, con el chat de Claudia. El fichero nació con el ETL
   Hub, que es «el estado de algo que se mueve solo», y por eso hasta ahora
   solo tenía panel de salud: semáforo, piezas, avisos. Pero DESIGN.md §3-D
   describe DOS formas —«el contenido crece por abajo y el compositor está
   anclado», «indicadores de estado de sesión»— y esa segunda no existía en
   ninguna parte del núcleo. Es la unidad de trabajo de Mensajería y de
   Claudia: LA CONVERSACIÓN.

   Se escribe aquí y no en la hoja de Claudia porque Mensajería la va a pedir
   igual, y la lección de las trece bifurcaciones es exactamente esa. Va sin
   prefijo de arquetipo —`ds-chat`, no `ds-d__chat`— por el mismo criterio que
   `.ds-etapas` en el B: es una pieza reutilizable, no la estructura de la
   pantalla.

   LO QUE NO SE CONSERVA DEL CHAT ANTERIOR, y es el motivo de la mitad de este
   bloque: era una **terminal negra** con el verde de marca de prompt. §3-D lo
   prohíbe por escrito —«fondo neutro, no oscuro, para no romper con el resto
   de la plataforma»— y no era una excusa estética: la consola vivía en el
   mismo portal claro que el resto y se leía como una ventana ajena incrustada.
   Lo que sí se conserva es la voz monoespaciada de los turnos, que aquí SÍ
   informa: se está leyendo lo que un modelo genera palabra a palabra, y la
   anchura fija es lo que deja comparar dos respuestas.

   ⚠️ VIVIÓ EN `archetypes/d-tiempo-real.css` HASTA EL 2026-08-31, con el
   párrafo de arriba —«es una pieza reutilizable, no la estructura de la
   pantalla»— desmintiendo dónde estaba. Se mudó aquí el día que hizo falta en
   otro arquetipo: la curación del banco de pruebas es **C** —se lee un caso,
   no se vigila algo que se mueve— y `base_eval.html` carga UNA hoja de
   arquetipo, la que nombra la vista. La conversación que hay que juzgar no
   podía pintarse como conversación, y el fallo habría sido el mudo de siempre:
   las clases se escriben, no dan error y no pintan.
   ========================================================================= */

.ds-chat {
  display: flex;
  flex-direction: column;
  min-height: 0;
  height: 100%;
  background: var(--n-0);
}

/* LA CONVERSACIÓN COMO CONTENIDO, no como panel vivo. En el panel dividido el
   hilo es la única región que scrollea y el compositor se queda anclado abajo;
   eso es correcto cuando la pantalla ES la conversación y falso cuando la
   conversación es una PRUEBA dentro de un documento que se lee entero. Sin
   este modificador, un hilo dentro de un panel de arquetipo C se encierra en
   su propia caja con su propia barra, y leer cuatro mensajes cuesta dos
   scrolls anidados —el de la página y el suyo—, que es la forma más segura de
   que nadie llegue al último turno.

   Solo apaga el scroll: el relleno del hilo, las burbujas y la alternancia
   izquierda/derecha son los mismos. Quien lo use pone el cuerpo del panel a
   sangre (`ds-panel__body--flush`), o el relleno se cuenta dos veces. */
.ds-chat--estatico { height: auto; }

.ds-chat--estatico .ds-chat__hilo {
  flex: none;
  overflow: visible;
}

/* Cabecera: quién contesta y en qué estado está la sesión. */
.ds-chat__cab {
  display: flex;
  align-items: center;
  gap: var(--s-5);
  padding: var(--s-5) var(--s-6);
  border-bottom: var(--bd-strong);
}

.ds-chat__quien {
  font-weight: 600;
  display: flex;
  align-items: baseline;
  gap: var(--s-4);
  min-width: 0;
}

/* Quien contesta puede llamarse «María Dolores Sánchez Peñalver»: sin tope, en
   una cabecera flex el nombre parte por palabras y en móvil se comía cuatro
   renglones, empujando fuera de pantalla el botón de la derecha. Se trunca. */
.ds-chat__quien > * {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Estado de sesión CON TEXTO, no solo el punto: §3-D lo pide expresamente y
   §5 lo exige. El punto reutiliza `.ds-d__punto`, que ya distingue por forma. */
.ds-chat__estado {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  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);
}

/* EL HILO. La única región que hace scroll, y crece por abajo. */
.ds-chat__hilo {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: var(--s-6);
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}

.ds-chat__turno {
  max-width: 76ch;
  padding: var(--s-5) var(--s-6);
  border-radius: var(--r-md);
  background: var(--n-50);
  font-size: var(--fs-base);
  line-height: var(--lh-base);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* LA VOZ MONOESPACIADA ES UN MODIFICADOR, NO EL DEFECTO (2026-08-09, con
   Mensajería). El turno nació en mono con Claudia y el motivo estaba escrito:
   «se lee lo que un modelo genera palabra a palabra, y la anchura fija es lo
   que deja comparar dos respuestas». Eso es cierto de una consola de modelo y
   falso de una conversación con un paciente, que es texto humano y se lee
   mejor proporcional. Como es una propiedad de TODO el hilo y no de un turno
   suelto, el modificador va en el contenedor: `.ds-chat--maquina`. La traza
   técnica conserva la mono siempre, porque ahí sigue informando. */
.ds-chat--maquina .ds-chat__turno,
.ds-chat--maquina .ds-chat__texto,
.ds-chat__traza {
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
}

/* Lo que escribe la persona va a la derecha y en acento suave: es uno de los
   cinco sitios del acento —la selección— y aquí hace de «esto lo he dicho yo».
   La alternancia izquierda/derecha es lo que permite recorrer un hilo largo
   sin leer quién habla en cada turno. */
.ds-chat__turno--propio {
  align-self: flex-end;
  background: var(--accent-bg);
}

.ds-chat__turno--sistema {
  align-self: center;
  max-width: 68ch;
  background: none;
  color: var(--n-500);
  font-size: var(--fs-xs);
  text-align: center;
}

/* El coste de la respuesta (tiempo, tokens/s) y cualquier otra anotación de
   máquina: se lee si se busca y no compite con el texto. */
.ds-chat__meta {
  align-self: flex-start;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  color: var(--n-500);
}

/* Volcado técnico dentro del hilo: la traza de una decisión, un JSON. Conserva
   los saltos y no se estira: es para leerlo, no para escanearlo. */
.ds-chat__traza {
  align-self: stretch;
  max-width: none;
  background: var(--n-25);
  border: var(--bd-strong);
}

.ds-chat__fila { display: flex; gap: var(--s-5); }
.ds-chat__rotulo { flex: none; font-weight: 600; color: var(--n-600); }
.ds-chat__fila--tenue { color: var(--n-500); }
.ds-chat__fila--salida { color: var(--ok); }
.ds-chat__fila--fallo  { color: var(--risk); }

/* EL COMPOSITOR, ANCLADO ABAJO. Es el otro requisito literal de §3-D. */
.ds-chat__compositor {
  /* Posicionado porque los popover del compositor (§29) se anclan a él con
     `bottom: 100%`. Sin esto su ancestro posicionado más cercano es el panel
     entero y la caja de emoticonos se abre FUERA de la pantalla, por arriba. */
  position: relative;
  display: flex;
  align-items: flex-end;
  gap: var(--s-5);
  padding: var(--s-5) var(--s-6);
  border-top: var(--bd-strong);
  background: var(--n-25);
}

.ds-chat__texto {
  flex: 1;
  min-height: var(--ctl-h);
  max-height: 8rem;
  resize: none;
  font-size: var(--fs-base);
}

/* Barra de contexto entre el hilo y el compositor: los parámetros con los que
   se está simulando. No es parte del hilo —no es algo que alguien dijera— así
   que va fuera de la región que scrollea. */
.ds-chat__contexto {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-5);
  padding: var(--s-4) var(--s-6);
  border-top: var(--bd);
  background: var(--n-25);
  font-size: var(--fs-xs);
}

/* En pantalla estrecha la cabecera y el compositor envuelven. Es el mismo
   defecto que DESIGN.md §3-B documenta para el expediente —cabecera `nowrap`
   correcta en escritorio que en móvil expulsa la acción fuera de la pantalla—
   y aquí se daba dos veces: el nombre partía en cuatro renglones y el
   compositor dejaba la caja de escribir en 60px de ancho. Medido a 390px. */
@media (max-width: 640px) {
  .ds-chat__hilo { padding: var(--s-5); }
  .ds-chat__turno { max-width: 100%; }
  .ds-chat__compositor { padding: var(--s-5); flex-wrap: wrap; }
  .ds-chat__cab { flex-wrap: wrap; }

  /* La caja de escribir sube a su propio renglón y los botones quedan debajo:
     en un teléfono el ancho de la caja importa más que la fila de iconos. */
  .ds-chat__texto {
    order: -1;
    flex: 1 1 100%;
  }
}

/* =========================================================================
   28. LA CONVERSACIÓN HUMANA
   Lo que le faltaba a `ds-chat` (§27) para servir a Mensajería: separador de
   día, entrega del mensaje y meta dentro de la burbuja.
   ========================================================================= */

/* El turno entrante ocupa la izquierda con fondo blanco y contorno: en un hilo
   de dos voces, blanco contra `--accent-bg` separa más que dos grises. */
.ds-chat__turno--entrante {
  align-self: flex-start;
  background: var(--n-0);
  border: var(--bd-strong);
}

.ds-chat__turno--seleccionable { cursor: pointer; }

.ds-chat__turno--seleccionado {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* EL TURNO BAJO ESCRUTINIO, que no es lo mismo que un turno seleccionado: no
   lo ha elegido nadie con el ratón, es el motivo de que la pantalla exista —el
   mensaje que hay que etiquetar en la curación, el que disparó una alerta—. Va
   con `--foco` y NUNCA solo con el contorno: encima lleva su rótulo
   (`.ds-chat__dia--foco`), que es la palabra que §5 exige al lado del color.

   El acento es el mismo de la selección porque es el mismo trabajo —«este de
   aquí»— y el sistema tiene un solo acento. El contorno se separa dos píxeles
   en vez de uno: el turno en foco convive con burbujas propias, que ya son
   `--accent-bg`, y a un píxel el contorno se leía pegado a ellas. */
.ds-chat__turno--foco {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* LA RÁFAGA: varios mensajes seguidos que son UN turno.
   Añadido el 2026-09-04 con la curación, y va en el núcleo y no en la hoja de
   la app porque una ráfaga es un hecho de `mensajeria` —lo que alguien escribe
   seguido, `rafagas.VENTANA_RAFAGA`— y la bandeja la va a pedir igual.

   El defecto que corrige: la pantalla de etiquetar cosía la ráfaga en UNA
   burbuja con UNA hora, y **210 de los 493 casos de la primera tanda eran
   ráfagas de 2+**. Quien etiquetó no podía ver que eran tres mensajes ni con
   qué ritmo se escribieron, y el ritmo cambia el sentido: «perdón por las
   molestias» pegado detrás de «ayer no pude ir» es una disculpa.

   Con UN solo mensaje esto no pinta nada —ni línea, ni hueco, ni separación
   extra—, que es el otro requisito: la mayoría de los casos son de uno y no se
   les puede añadir ruido. */
.ds-chat__rafaga {
  align-self: flex-start;
  max-width: 100%;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s-2);
}

/* EL RESALTE ABARCA LA RÁFAGA ENTERA, no cada burbuja.
   `--foco` (arriba) marca UN turno bajo escrutinio; cuando el turno son tres
   mensajes, tres contornos a dos píxeles se tocan y se leen como tres cosas
   distintas — justo lo contrario de lo que hay que ver. El contorno se sube al
   grupo, y la palabra sigue estando encima (`.ds-chat__dia--foco`): las dos
   cosas y no solo el color, que es lo que exige §5. */
.ds-chat__rafaga--foco {
  outline: 2px solid var(--accent);
  outline-offset: var(--s-4);
  border-radius: var(--r-md);
}

/* EL NEXO: la línea que encadena dos mensajes de la ráfaga, y cuya ALTURA dice
   cuánto tiempo pasó entre ellos.

   ⚠️ La altura sale de `data-hueco`, nunca de un `style="height:…"` en el
   marcado: el tramo lo decide el servicio (`curacion.TRAMOS_DEL_HUECO`) y la
   escala vive aquí, que es donde el tema puede alcanzarla.

   ⚠️ Y NO es una escala lineal. El hueco está acotado en 3 minutos por la
   ventana de la ráfaga, así que a tamaño real 2 s es invisible y 170 s rompe
   la pantalla. Son cuatro tramos con cuatro alturas, y cada uno se lee también
   **en cifra** (`__nexo-hueco`) y **en palabra** (el `title`): una señal que
   solo es geometría no la lee quien va rápido. */
.ds-chat__nexo {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  margin-left: var(--s-7);
  height: var(--ds-nexo-h, var(--s-4));
}

.ds-chat__nexo-linea {
  width: var(--s-1);
  height: 100%;
  background: var(--n-300);
}

.ds-chat__nexo-hueco {
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-variant-numeric: tabular-nums;
  color: var(--n-500);
}

.ds-chat__nexo[data-hueco="seguido"]  { --ds-nexo-h: var(--s-4);  }
.ds-chat__nexo[data-hueco="segundos"] { --ds-nexo-h: var(--s-7);  }
.ds-chat__nexo[data-hueco="medio"]    { --ds-nexo-h: var(--s-9);  }
.ds-chat__nexo[data-hueco="largo"]    { --ds-nexo-h: var(--s-11); }

/* Separador de día. No es un turno —nadie lo dijo— así que no lleva burbuja. */
.ds-chat__dia {
  align-self: center;
  padding: var(--s-2) var(--s-5);
  border-radius: var(--r-pill);
  background: var(--n-100);
  font-family: var(--font-cond);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--track-micro);
  text-transform: uppercase;
  color: var(--n-600);
}

/* Pie de la burbuja: canal, hora y entrega. */
.ds-chat__pie {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--s-3);
  margin-top: var(--s-3);
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  color: var(--n-500);
}

/* ENTREGA — y aquí está la corrección de un defecto real, no de estilo.
   «Entregado» y «leído» pintaban los dos `✓✓` y se distinguían SOLO por el
   color del segundo, que §5 prohíbe y que con deuteranopia no existe. Ahora la
   escala se lee por FORMA y por PALABRA: pendiente (reloj), enviado (un tick),
   entregado (dos ticks), leído (dos ticks + la palabra) y fallido (icono + «no
   enviado»). El color se queda como refuerzo del que ya lo dice todo sin él. */
.ds-chat__entrega {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
}

.ds-chat__entrega--leido  { color: var(--info); }
.ds-chat__entrega--fallo  { color: var(--risk); font-weight: 600; }

/* Adjunto dentro de la burbuja. */
.ds-chat__adjunto {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  margin-bottom: var(--s-4);
  padding: var(--s-4) var(--s-5);
  border-radius: var(--r-sm);
  background: var(--n-100);
  font-size: var(--fs-sm);
}

/* BUSCAR DENTRO DEL HILO. Una barra bajo la cabecera, no un popover: mientras
   se busca hay que seguir viendo la conversación, y lo que se teclea acota lo
   que se está mirando. Es del núcleo y no de Mensajería porque la bandeja de
   Claudia tiene el mismo hilo y va a pedir lo mismo. */
.ds-chat__buscar {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-4) var(--s-6);
  border-bottom: var(--bd-strong);
  background: var(--n-25);
}

/* `.ds-input` es `width: 100%`: dentro de la barra el texto se reparte lo que
   sobra y la fecha se queda con su ancho natural, o el selector nativo se
   estruja hasta no poder pulsarlo. */
.ds-chat__buscar-q     { flex: 1; min-width: 8rem; }
.ds-chat__buscar-fecha { flex: none; width: auto; }

/* El contador y las tres teclas son una pieza y envuelven juntas. */
.ds-chat__buscar-nav {
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--s-4);
  margin-left: auto;
}

.ds-chat__buscar-n {
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* LA COINCIDENCIA. Todas se marcan con fondo; la que se está mirando lleva
   ADEMÁS contorno y negrita, que es forma: distinguir «la activa» de «las
   demás» solo por el matiz del fondo es exactamente lo que §5 no permite. */
.ds-chat__match {
  background: var(--warn-bg);
  color: inherit;
  border-radius: var(--r-sm);
}

.ds-chat__match--activa {
  outline: 2px solid var(--warn);
  outline-offset: 1px;
  font-weight: 600;
}

/* El separador del día al que ha saltado la búsqueda por fecha: sin esto, un
   salto a un día sin coincidencias de texto no deja ninguna señal de haber
   ocurrido y parece que el control no hace nada. */
.ds-chat__dia--diana {
  background: var(--warn-bg);
  color: var(--warn);
}

/* El rótulo del turno en foco. Misma pieza que el separador de día —una
   pastilla centrada, que no es un turno porque nadie la dijo— y en el acento,
   para que la palabra y el contorno de la burbuja de debajo digan lo mismo. */
.ds-chat__dia--foco {
  background: var(--accent-bg);
  color: var(--accent);
}

/* A un ancho de móvil no caben los dos campos y las teclas en un renglón: el
   texto se queda con el suyo entero y abajo van la fecha y la navegación. */
@media (max-width: 640px) {
  .ds-chat__buscar { flex-wrap: wrap; padding: var(--s-4) var(--s-5); }
  .ds-chat__buscar-q { flex-basis: 100%; }
}

/* =========================================================================
   29. EL POPOVER DEL COMPOSITOR
   Emoticonos y plantillas: dos cajas que se abren sobre el compositor y se
   cierran al elegir. No es un modal —no bloquea, no vela la pantalla y no
   pide confirmación— y por eso no reutiliza `.ds-modal`.
   ========================================================================= */

.ds-chat__pop {
  position: absolute;
  bottom: 100%;
  left: 0;
  right: 0;
  z-index: 20;
  max-height: 18rem;
  overflow-y: auto;
  border: var(--bd-strong);
  border-radius: var(--r-md) var(--r-md) 0 0;
  background: var(--n-0);
  box-shadow: var(--sh-overlay);
}

.ds-chat__pop-cab {
  position: sticky;
  top: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-4) var(--s-5);
  border-bottom: var(--bd);
  background: var(--n-0);
  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-chat__pop-grupo { border-bottom: var(--bd); }
.ds-chat__pop-grupo:last-child { border-bottom: none; }

.ds-chat__pop-grupo-tit {
  padding: var(--s-3) var(--s-5);
  background: var(--n-25);
  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-chat__pop-item {
  display: block;
  width: 100%;
  padding: var(--s-4) var(--s-5);
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

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

.ds-chat__emojis {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(2.25rem, 1fr));
  gap: var(--s-2);
  padding: var(--s-4);
}

.ds-chat__emoji {
  aspect-ratio: 1;
  border: 0;
  border-radius: var(--r-sm);
  background: none;
  font-size: var(--fs-lg);
  line-height: 1;
  cursor: pointer;
}

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


/* =========================================================================
   25. LA OPCIÓN QUE SE RECONOCE POR SU LOGO
   Sube al núcleo el 2026-09-03, al cortar el puente de Contabilidad.

   Es el mismo problema que `.ds-picker` resolvió para el almacén: **hay listas
   donde el identificador no es el texto**. Nadie lee «Banco Santander, cuenta
   terminada en 4900»: se reconoce el logo rojo. Un `<select>` nativo no admite
   imágenes dentro de un `<option>`, y de ahí que Contabilidad tuviera
   construido a mano un desplegable entero desde JavaScript —`icono-dropdown*`,
   19 reglas y ~120 líneas—, con la nota escrita en su plantilla: «el núcleo no
   tiene ese componente; queda anotado como hueco del núcleo». Esto lo cierra.

   Son DOS, y la diferencia importa:

   · `.ds-selicono` — **elegir de una lista larga**: cuentas bancarias, tarjetas,
     métodos de cobro. Viste a un `<select>` de verdad, que sigue en el marcado y
     es lo que se envía: si el JavaScript no corre, el `<select>` nativo queda
     visible y operativo. No lo sustituye, lo viste.
   · `.ds-iconos` — **elegir el icono en sí**, de un juego corto y conocido de
     antemano (una docena de logos de banco). Ahí no hay lista que desplegar: se
     ven todos a la vez, son `<input type="radio">` de verdad y el foco y el
     teclado los da el navegador.

   ⚠️ El icono NUNCA va solo. En `.ds-selicono` el nombre viaja al lado en el
   menú, y en `.ds-iconos` debajo de cada casilla: un logotipo es un color y una
   forma, o sea justo lo que §5 no admite como única señal. Y la opción vacía se
   dice con `—` y borde discontinuo, no solo con la ausencia de imagen.
   ========================================================================= */

/* ---- Elegir de una lista: el <select> vestido ------------------------- */

.ds-selicono { position: relative; display: inline-block; vertical-align: middle; }

/* El <select> nativo, una vez construido el desplegable: fuera de la vista pero
   dentro del formulario. `clip` y no `display: none` para que siga siendo la
   fuente de verdad y el respaldo sin JavaScript. */
.ds-selicono__native {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  clip-path: inset(50%);
}

.ds-selicono__trigger {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  max-width: 100%;
  min-height: var(--ctl-h);
  padding: 0 var(--s-4);
  border: var(--bd-strong);
  border-radius: var(--r-sm);
  background: var(--n-0);
  line-height: 1;
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease);
}

.ds-selicono__trigger:hover { border-color: var(--n-400); }
.ds-selicono__trigger[aria-expanded="true"] { border-color: var(--accent); }
.ds-selicono__trigger:focus-visible { box-shadow: var(--focus); outline: 0; }

/* Sin elegir: el borde discontinuo lo dice sin depender del color, igual que
   `.ds-drop` dice «aquí se suelta algo». */
.ds-selicono__trigger--vacio { border-style: dashed; }

.ds-selicono__img { display: block; width: 22px; height: 22px; border-radius: var(--r-sm); }
.ds-selicono__fa { width: 20px; text-align: center; color: var(--n-500); }
.ds-selicono__none { padding: 0 var(--s-2); font-weight: 600; color: var(--n-400); }

.ds-selicono__caret {
  margin-left: auto;
  padding-left: var(--s-2);
  font-size: var(--fs-xs);
  color: var(--n-400);
}

.ds-selicono__menu {
  position: absolute;
  top: calc(100% + var(--s-2));
  left: 0;
  z-index: 1080;
  min-width: 200px;
  max-height: 320px;
  overflow-y: auto;
  padding: var(--s-2);
  border: var(--bd-strong);
  border-radius: var(--r-lg);
  background: var(--n-0);
  box-shadow: var(--sh-overlay);
  display: none;
}

.ds-selicono[data-abierto] > .ds-selicono__menu { display: block; }

.ds-selicono__item {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  width: 100%;
  padding: var(--s-3) var(--s-4);
  border: 0;
  border-radius: var(--r-sm);
  background: none;
  color: var(--n-700);
  font: inherit;
  font-size: var(--fs-sm);
  text-align: left;
  cursor: pointer;
}

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

/* El elegido: fondo Y peso. Solo con el fondo, en un menú de logos de colores,
   no se distingue del que tienes debajo del ratón. */
.ds-selicono__item[aria-selected="true"] {
  background: var(--accent-bg);
  color: var(--accent-600);
  font-weight: 600;
}

.ds-selicono__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }

/* En una ficha el desplegable ocupa el ancho del campo, no el de su contenido. */
.ds-selicono--ancho,
.ds-selicono--ancho > .ds-selicono__trigger { display: flex; width: 100%; }

/* ---- Elegir el icono: el juego corto, todo a la vista ----------------- */

.ds-iconos {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-5);
}

/* 72px y no 56: el ancho lo manda el ROTULO, no el icono. Con 56 no cabía
   «Automático» sin partirse por la mitad («Automátic / o»), y ninguna de las dos
   salidas fáciles vale — recortar con puntos suspensivos deja «Banco Sa…» en
   Santander y en Sabadell, o sea el rótulo dejando de desambiguar justo entre
   las dos casillas que más se parecen. */
.ds-iconos__opt {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 72px;
  cursor: pointer;
}

/* El radio de verdad, invisible pero presente: el foco, las flechas del teclado
   y el envío del formulario los sigue dando el navegador. */
.ds-iconos__opt > input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  opacity: 0;
  clip-path: inset(50%);
}

.ds-iconos__tile {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  border: 2px solid var(--n-200);
  border-radius: var(--r-lg);
  background: var(--n-0);
  color: var(--n-400);
  transition: border-color var(--dur-fast) var(--ease);
}

.ds-iconos__tile > img { width: 30px; height: 30px; }

.ds-iconos__opt > input:checked ~ .ds-iconos__tile {
  border-color: var(--accent);
  background: var(--accent-bg);
}

.ds-iconos__opt > input:focus-visible ~ .ds-iconos__tile { box-shadow: var(--focus); }

/* El nombre debajo, siempre: ver el aviso de la cabecera.

   ENVUELVE, no recorta, y es lo único que lo hace servir para algo. Con
   `text-overflow: ellipsis` sobre 56px, «Banco Santander» y «Banco Sabadell»
   salían los dos como «Banco Sa…»: el rótulo dejaba de desambiguar justo entre
   las dos casillas que más se parecen, y entonces lo único que las distingue es
   el color del logo — que es exactamente lo que el aviso de la cabecera dice
   que no puede pasar. Dos renglones cuestan 12px y se leen. */
.ds-iconos__nombre {
  max-width: 72px;
  margin-top: var(--s-2);
  font-size: var(--fs-micro);
  line-height: var(--lh-tight);
  color: var(--n-500);
  text-align: center;
  overflow-wrap: break-word;
}

.ds-iconos__opt > input:checked ~ .ds-iconos__nombre {
  color: var(--accent-600);
  font-weight: 600;
}
