# Contrato del sistema de diseño — index-relay

Esto no es una sugerencia por vista: es lo que las seis propuestas tienen que usar tal cual. Si una
vista necesita algo que acá no está, se agrega **acá** y después se usa; no se dibuja aparte.

Todo lo de abajo sale de lo que ya existe en el repositorio. Los tres anclajes que no se mueven:

- `site-header.tsx` rinde el `<h1>` con `text-base font-medium` (16 px / 500) en una franja de alto
  fijo. Es el único `h1` del producto.
- `AppLayout` rinde `<main className="flex flex-1 flex-col gap-6 p-4 lg:p-6">` y, si se le pasa
  `description`, un `<p className="text-muted-foreground text-sm text-pretty">`. O sea:
  el ritmo vertical entre secciones **ya está resuelto en 24 px** y la bajada de la página **ya
  tiene su estilo**. Ninguna vista los redefine.
- `ui/card.tsx` (estilo `radix-nova`) usa `rounded-xl`, `ring-1 ring-foreground/10` y
  `--card-spacing: 16px`, con `CardTitle` en `text-base font-medium`. Las tarjetas dibujadas a mano
  usan `rounded-lg border p-4`. Por eso «las de un lado son distintas de las del otro»: son dos
  formas distintas de verdad, con otro radio y otro borde.

---

## 1. Escala tipográfica

Seis pasos. Nada afuera de estos seis.

| Paso | Rol | Clases | Tamaño / peso | Color | Etiqueta semántica |
|---|---|---|---|---|---|
| **T1** | Título de página | `text-xl font-semibold tracking-tight` | 20 px / 600 | `text-foreground` | `<h1>` |
| **T0** | Localizador de la barra | `text-base font-medium` | 16 px / 500 | `text-foreground` | ninguna (`<nav>`/`<span>`) |
| **T2** | Dato principal | `text-2xl font-semibold tabular-nums` | 24 px / 600 | `text-foreground` | ninguna (`<div>`/`<p>`) |
| **T3** | Título de sección | `font-heading text-base leading-snug font-medium` | 16 px / 500 | `text-foreground` | `<h2>` |
| **T4** | Título de bloque | `text-sm font-medium` | 14 px / 500 | `text-foreground` | `<h3>` |
| **T5** | Cuerpo | `text-sm` | 14 px / 400 | `text-foreground` · `text-muted-foreground` si es prosa explicativa | `<p>`, `<td>`, `<dd>`, `<li>` |
| **T6** | Metadato | `text-xs` (500 en badges y `<th>`) | 12 px / 400–500 | `text-muted-foreground` | `<dt>`, `<th>`, `<caption>`, pies |

### Cuándo se usa cada uno

- **T1** — el título de la pantalla, y **el único `<h1>`**. Lo escribe `PageHeading` y lo rinde
  `AppLayout`: ninguna vista lo dibuja a mano ni elige su tamaño (D8). Está por encima de T3 —es el
  techo de la columna de contenido— y por debajo de T2, para no competir con la cifra que contesta
  la pregunta de la vista.
- **T0** — el localizador de la barra superior: «dónde estoy», no el titular de la pantalla. **No es
  un encabezado**: la barra es navegación y su nombre de vista es un `<span>` dentro de un `<nav>`.
  Hasta D8 esto era T1 y era el único encabezado del producto, que es de donde venía que las trece
  vistas no tuvieran ninguno propio.
- **T2** — el número que contesta la pregunta de la vista. **Una cifra o hasta cinco palabras.**
  Nunca una oración, nunca una palabra de estado (hoy V6 dibuja «En cola» a 24 px/600 como si fuera
  una cifra: eso es T4 más un `StatusBadge`), nunca un porcentaje sin su denominador al lado.
  Siempre `tabular-nums`. Como mucho una fila de T2 por pantalla.
- **T3** — el título de **toda** `Section` y de **toda** `Card`. Es el default de `CardTitle`, así
  que en una tarjeta no se escribe: se hereda. Fuera de una tarjeta lo escribe `Section`.
- **T4** — un bloque con nombre **adentro** de una sección que ya tiene su T3: un bolsillo de cupo,
  un grupo de campos, un grupo de pares etiqueta/valor, el título de un `EmptyState`. Es lo que da
  `CardTitle` cuando la `Card` lleva `size="sm"`.
- **T5** — el default. `Card` y `<main>` ya lo fijan; **no se repite `text-sm` en sus hijos.** La
  prosa explicativa va en `text-muted-foreground` y siempre con `max-w-prose`.
- **T6** — encabezados de columna, pies, leyendas, la ayuda debajo de un campo, el texto de un
  badge, el sello de zona horaria. Es el piso: nada más chico existe.

### Qué pasa con `h2` y `h3`

Hoy el mismo nivel está dibujado de siete maneras (`H2 20/500`, `H2 18/500`, `H2 16/500`,
`H2 16/400`, `H2 14/500`, `H3 14/500`, `H3 14/600`) y tres vistas —V4, V5, V12— no tienen ningún
encabezado. Se cierra así:

- `h1` = T1, uno por pantalla, lo pone el armazón con `PageHeading`, **dentro del contenido**. La
  barra superior no lleva ninguno.
- `h2` = T3, **siempre**. Uno por `Section` y uno por `Card`. Si el título no se tiene que ver
  —las regiones de filtros de V7 y V8— sigue siendo un `h2` T3 con `sr-only`; lo que se oculta es
  el dibujo, no el nivel.
- `h3` = T4, **siempre**, y sólo adentro de una sección que ya tiene su `h2`. No se saltea de `h2`
  a `h3` sin `h2`.
- **No hay `h4` ni más abajo.** Un cuarto nivel es la señal de que ese contenido tenía que estar en
  un `Sheet`, en un `Accordion` o en su propia página (ver §4).
- T2 **nunca** es una etiqueta de encabezado. Es el valor; su nombre accesible se lo da el `h2` o
  el `h3` que tiene arriba.

### Lo que queda prohibido

- `text-lg` (18 px), `text-xl` (20 px), `text-3xl` (30 px). Los tres desaparecen. El `text-3xl` del
  tablero sale de un `@[250px]/card:text-3xl` en `section-cards.tsx`: se borra la consulta de
  contenedor y la cifra queda clavada en T2.
- `font-bold` y `font-normal` explícitos. `font-semibold` existe **sólo** en T2.
- Cualquier `text-[...]` escrito en una página. El `12,8 px` medido no es un valor suelto de nadie:
  es el `text-[0.8rem]` de `Button size="sm"` y `Toggle size="sm"` del propio estilo `radix-nova`.
  Es un tamaño de **control**, no un paso de texto. Se lo deja donde está y la regla pasa a ser
  otra: **no se mezclan `size="sm"` y `size="default"` en la misma fila de controles.**
- `title` como único lugar donde vive un dato (RT-02). Ver `Tooltip` en §4.

---

## 2. Escala de espaciado y densidad

Base 4 px (`--spacing` de Tailwind 4). Cinco valores y nada más: **4 · 8 · 12 · 16 · 24**.

| Relación | Valor | Clase |
|---|---|---|
| Entre secciones de una página | 24 px | `gap-6` — **ya lo da `<main>`** |
| Entre tarjetas de una misma fila | 16 px | `gap-4` |
| Adentro de una tarjeta (padding y gap) | 16 px (12 px si `size="sm"`) | `--card-spacing`, nunca `p-*` propio |
| Encabezado de sección → su contenido | 12 px | `gap-3` |
| Entre ítems hermanos de una lista o pila | 8 px | `gap-2` |
| Etiqueta → su valor | 4 px | `gap-1` |
| Fila de tabla (vertical) | 12 px | `py-3` (densidad estándar) |
| Fila de tabla compacta | 8 px | `py-2`, sólo si **todas** las celdas son de una línea |

Reglas:

- **Ninguna página envuelve a sus hijos en un contenedor con otro `gap`.** El ritmo de 24 px sale
  de `<main>`; un `<div className="space-y-8">` alrededor lo rompe y es lo que hace que dos vistas
  respiren distinto.
- **Ninguna `Card` lleva `p-4`, `p-5` ni `px-3 py-2` propios.** Hoy ApiKeys usa `p-5`, Coverage
  `p-4`, Batches `px-3 py-2` y el Wizard mezcla los tres. El espaciado sale de `--card-spacing`; lo
  único que se elige es `size="default"` o `size="sm"`.
- **Ancho máximo de una columna de texto: `max-w-prose`** (~65 ch). Vale para la `description` de
  la página —ya lo tiene—, para toda `CardDescription`, para todo párrafo explicativo y para la
  descripción de un `EmptyState`. Una línea de prosa que cruza los 1130 px de la columna de
  contenido es ilegible; es lo que pasa hoy en V6 («Te avisamos en la plataforma cuando…»).
- **`text-pretty` en párrafos, `text-balance` en títulos de dos o más líneas.**
- **`min-w-0` en todo hijo de flex o grid que contenga texto**, y `wrap-anywhere` en las celdas que
  llevan URLs. Sin eso una URL larga empuja la página entera.

### Una columna o dos

Por default **una columna**, a ancho completo del contenido. Se pasa a dos sólo en estos tres
casos, y siempre con `grid grid-cols-1 md:grid-cols-N gap-4`:

1. Una fila de `MetricCard`: 2, 3 o 4 tarjetas de **igual ancho**. Nunca 1 (eso es un dato suelto,
   va en el `PageIntro`), nunca 5 o más.
2. Un formulario con campos cortos y apareados por significado («Consultas por día» / «Reservadas
   para uso manual»).
3. Una sección con un panel de detalle permanente al costado. Si el detalle es de una fila, no es
   este caso: es un `Sheet` (§4).

Prohibido: el reparto asimétrico `2fr / 1fr` entre dos piezas del **mismo** tipo. Es lo que hace
`CoverageSummary` hoy con «Con dato de Google» y «Sin consultar todavía»: son las dos mitades del
mismo total y el ancho las convierte en una principal y una nota al pie. Dos mitades, dos anchos
iguales.

Todo se apila abajo de `md`. Nada de `flex-wrap` con anchos fijos.

---

## 3. Anatomía única de las piezas

Nombres en inglés. Cada pieza tiene **una** anatomía; el que necesite otra cosa cambia el contrato,
no la pieza.

### 3.1 `PageIntro` — el bloque bajo el encabezado

- **Qué es.** La línea de estado de la página. El encabezado es una franja de alto fijo de una
  línea, así que el badge de estado y la fecha de última comprobación —que hoy en V6 cuelgan del
  `actions` o flotan sueltos— viven acá.
- **De dónde sale.** Composición: `Badge` (vía `StatusBadge`) + `DataTimestamp` + separadores `·`.
- **Relación con `description`.** La bajada de la página sigue siendo el prop `description` de
  `AppLayout`, que ya la rinde en T5 muted a `max-w-prose`. `PageIntro` va **inmediatamente
  después** y sólo lleva el estado. Una vista no escribe su propia bajada.
- **Estructura fija** — una sola línea a 1280 px, nunca tres:
  ```
  <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
    {badge}                       ← StatusBadge, opcional, siempre primero
    {items.map(…)}                ← hasta 3, separados por · (aria-hidden)
  </div>
  ```
- **Props.** `{ badge?: ReactNode; items?: ReactNode[]; className?: string }`
- **Qué reemplaza.** La fila suelta de V6 (`Operativo` + «Última comprobación: anteayer»), el
  identificador suelto de V7 y todo intento de meter una fecha en `actions`.

### 3.2 `Section` — la sección de página

- **Qué es.** El agrupador de contenido con título. Es la pieza que hoy no existe y por eso cada
  vista la improvisa.
- **De dónde sale.** No hay primitiva; es el envoltorio de disposición. Usa `Separator` cuando hace
  falta.
- **Estructura fija.**
  ```
  <section aria-labelledby={id} className="flex flex-col gap-3">
    <div className="flex flex-wrap items-start justify-between gap-2">
      <h2 id={id}> {title} </h2>            ← T3 (sr-only si titleHidden)
      {actions}                              ← una línea, máx. 2 controles o un ButtonGroup
    </div>
    {description && <p className="text-muted-foreground text-sm text-pretty">…</p>}
    {children}
  </section>
  ```
- **Props.** `{ title: SectionTitle; description?: string; actions?: ReactNode; id?: string;
  tone?: 'default' | 'destructive'; children: ReactNode; className?: string }`, donde
  `SectionTitle = { text: string; isHidden?: boolean; level?: 'h2' | 'h3'; className?: string }`.
  Todo lo del título va en un objeto porque es **una** decisión: repartida en props hermanas se
  puede escribir en combinaciones que no significan nada, como un `level` sobre un título que no se
  rinde.
- **Regla dura — con el título escondido no se rinde nada.** `isHidden` **no** dibuja un
  `sr-only`: no dibuja. Un encabezado invisible sigue siendo un hijo del flex y cobra su parte del
  `gap` aunque no muestre nada, y eso deja un hueco arriba del primer contenido visible que no
  separa nada. La sección conserva su nombre en un `aria-label`; lo que se cede es aparecer en el
  índice de encabezados, y por eso es la excepción y no el modo normal de usar la pieza.
- **Regla dura — `title.className` es salida de emergencia.** La pieza existe porque había `<h2>`
  dibujados de siete maneras distintas. Si el título tiene que verse distinto **porque la sección
  es de otra clase**, eso es `tone` —y si el tono no existe, se agrega a `tone`—; si tiene que
  verse distinto **en todas**, se cambia la pieza. **Nunca para color**: el color dice de qué clase
  es la sección y eso lo decide `tone`, para que la misma clase se vea igual en toda la aplicación.
- **Todo id que emite lleva el prefijo `section-`** (y `section-card-` en la tarjeta), incluido el
  del propio `<section>`, para poder reconocer la pieza en el inspector sin recorrer el árbol hacia
  arriba. Con el título escondido no hay encabezado que la delate, así que el id de la región es lo
  único que queda.
- **Regla dura.** **Una `Section` no tiene borde ni fondo.** El borde es de la `Card` y de la
  `DataTable`. Esto es lo que corta de raíz las cajas anidadas de V6 (tarjeta → tarjeta → tarjeta) y
  las ~20 apariciones de `<section className="… rounded-lg border p-4">` en Coverage, Dashboard,
  Settings y el Wizard.
- **Qué reemplaza.** Todos esos `<section>` a mano y todos los `<h2 className="text-lg …">`.

### 3.3 `SectionCard` — la tarjeta estándar del proyecto

- **Qué es.** Un bloque de contenido con identidad propia adentro de una sección, y **la forma por
  defecto de toda tarjeta del producto**. Es la contracara de `Section`: una agrupa sin dibujar caja
  y la otra dibuja la caja.
- **De dónde sale.** `components/SectionCard.tsx`, montada sobre la primitiva `@/components/ui/card`
  (`radix-nova`). Se decidió crear la pieza y no dejar la convención escrita porque la convención
  sola no se sostuvo: con la primitiva cruda había 22 usos en 9 archivos, y de 22 títulos **14 eran
  un `<div>` en vez de un encabezado** —se olvidaban del `asChild`—, la descripción repetía las
  mismas dos clases en 4 archivos y el contenido se espaciaba de 6 maneras distintas. Nada de eso
  era una decisión: era lo que pasa cuando la forma correcta hay que escribirla de nuevo en cada
  pantalla.
- **Estructura fija**, en este orden y sin saltos:
  ```
  <Card role="group" aria-labelledby={id}>                    ← ring, --card-spacing, sin border
    <CardHeader className="flex flex-row items-start justify-between gap-3">
      <div className="flex min-w-0 flex-col gap-1">
        <CardTitle asChild><h2|h3 id={id}>…</h2></CardTitle>  ← T3. OBLIGATORIO y siempre visible.
        {description && <CardDescription>…</CardDescription>} ← T5 muted, max-w-prose.
      </div>
      {actions && <div className="shrink-0">…</div>}          ← ranura ciega.
    </CardHeader>
    {children && <CardContent className="flex flex-col gap-4">…</CardContent>}
    {footer && <CardFooter>…</CardFooter>}                    ← ranura ciega, a lo ancho.
  </Card>
  ```
- **Props.** `{ title: SectionCardTitle; description?: string; actions?: ReactNode;
  footer?: ReactNode; id?: string; children?: ReactNode; className?: string;
  contentClassName?: string }`, donde
  `SectionCardTitle = Omit<SectionTitle, 'isHidden'>` — es decir
  `{ text: string; level?: 'h2' | 'h3'; className?: string }`.
- **Regla dura 1 — toda tarjeta lleva título, y el título se ve.** La firma sale de la de `Section`
  **menos `isHidden`**, así que la regla la sostiene el compilador y no un comentario. `Section` sí
  puede esconderlo, porque agrupa cosas que a veces no necesitan encabezado a la vista —una región
  de filtros—; una tarjeta no, porque ya es una caja con borde propio, y una caja con borde y sin
  nombre obliga a deducir qué contiene mirando lo que hay adentro. El nivel se declara (`h2` para
  una sección de la página, `h3` cuando vive dentro de una `Section` que ya aportó su `h2`) porque
  depende de dónde se monta, y eso sólo lo sabe quien la monta; el **dibujo no cambia** con el
  nivel.
- **Regla dura 2 — una ranura sin contenido no se rinde.** No es prolijidad: la `Card` reacciona a
  lo que tiene adentro —`has-data-[slot=card-footer]:pb-0`—, así que un pie vacío rendido por las
  dudas le cambia el relleno a la tarjeta entera y le deja abajo una franja con borde y fondo sin
  nada escrito. Lo mismo, en chico, con el contenedor de acciones: no dibuja nada y cobra su `gap`
  igual.
- **Regla dura 3 — `actions` y `footer` son ranuras ciegas.** La tarjeta reserva el lugar y no
  mira qué le ponen. Es la diferencia entre una pieza reusable y una que hay que ampliar cada vez
  que aparece un control nuevo.
- **Reglas heredadas de la primitiva.**
  - Nunca `border`: usa `ring-1 ring-foreground/10`. Agregarle borde le da dos filos.
  - Nunca se pisa `rounded-*`, `--card-spacing` ni el `ring`.
  - **Nunca una tarjeta adentro de otra tarjeta.** El segundo nivel es `Item` (§3.6) o un
    `Separator`.
  - En una fila de tarjetas: `h-full` en todas, y si una tiene pie con acción, **todas** lo tienen.
  - La tarjeta ya es `text-sm`: sus hijos no lo repiten.
- **Cuándo se baja a la primitiva cruda.** Sólo en las **tres** excepciones declaradas:
  `MetricCard` (§3.4), que invierte el orden porque su título es la cifra; `IndicatorCard`
  (§3.4b), que lo invierte al revés —título arriba, cifra en el contenido—; y la tarjeta
  enteramente clickable de Tools, que necesita `relative` + `after:inset-0`. Cualquier otra
  tarjeta usa `SectionCard`; si hiciera falta una cuarta, se agrega acá antes de escribirla.
- **Qué reemplaza.** Los 24 `rounded-lg border p-*` de `frontend/pages/` y los 22 usos de la
  primitiva cruda.

### 3.4 `MetricCard` — la tarjeta de métrica

- **Qué es.** Una cifra del tablero con lo que la mantiene honesta.
- **De dónde sale.** `Card size="sm"`, con la receta que ya usa `section-cards.tsx` menos la
  consulta de contenedor.
- **Estructura fija.**
  ```
  <Card size="sm" className="h-full">
    <CardHeader>
      <CardDescription>{label}</CardDescription>     ← T5 muted, ≤4 palabras
      <CardTitle className="text-2xl font-semibold tabular-nums">{value}</CardTitle>   ← T2
      <CardAction>{denominator}</CardAction>         ← el dato que sostiene la cifra
    </CardHeader>
    {note && <CardContent>…</CardContent>}           ← T5 muted, ≤2 líneas
    <CardFooter><DataTimestamp value={fetchedAt}/> {action}</CardFooter>
  </Card>
  ```
- **Props.** `{ label: string; value: ReactNode; denominator?: ReactNode; note?: string;
  fetchedAt?: string | null; action?: ReactNode; tone?: 'default' | 'unknown' }`
- **`tone="unknown"`** es la familia visual de RT-03: anillo punteado, sin relleno, muted, icono
  `Clock`. Así «Sin consultar todavía» es una **variante** de la misma tarjeta y no otra tarjeta
  dibujada aparte, que es lo que pasa hoy en V7.
- **Reglas.** El `value` es una cifra o un par de cifras («92 de 120»), jamás una palabra de
  estado. `tabular-nums` siempre. Ningún porcentaje sin `denominator` a la vista. Ninguna flecha de
  tendencia (el razonamiento ya está escrito en `section-cards.tsx` y se mantiene).
- **Qué reemplaza.** Las cuatro tarjetas de `section-cards.tsx`, las tres de V6 y los dos bloques
  de `CoverageSummary`.

### 3.4b `IndicatorCard` — la tarjeta de indicador

- **Qué es.** Un indicador con nombre propio: una cifra que **no es** el título de la tarjeta, sino
  la respuesta a la pregunta que el título hace.
- **Por qué no es `MetricCard`.** Son la misma caja con el orden invertido, y el orden es la
  decisión. En `MetricCard` la cifra **es** el encabezado —por eso su etiqueta va en
  `CardDescription`— y el dato que la sostiene se va a la esquina superior derecha. Acá el
  encabezado es el **nombre del indicador**, con su icono, y esa esquina queda libre para lo único
  que corresponde que viva ahí: las acciones. Forzar este uso dentro de `MetricCard` mandaba el
  contador a la esquina de las acciones y las acciones al pie, que fue exactamente lo que pasó.
- **De dónde sale.** `components/IndicatorCard.tsx`, sobre la primitiva. Es la receta que
  `ChildViews` de V6 ya venía escribiendo a mano, extraída.
- **Estructura fija.**
  ```
  <Card className={className}>
    <CardHeader>                                   ← una sola fila, nunca dos
      <CardDescription>{icon} {title}</CardDescription>
      <CardAction>{actions}</CardAction>           ← al otro extremo del título
    </CardHeader>
    <CardContent className="flex-1">               ← flex-1: empuja el pie al piso
      <CardTitle className="text-3xl">{value}</CardTitle>   ← 30 px, no los 24 de T2
      {description}                                ← T6 muted
      {children}
    </CardContent>
    {footer && <CardFooter>…</CardFooter>}         ← sólo si hay algo que poner
  </Card>
  ```
- **Props.** `{ title: string; icon?: LucideIcon; value: string; description?: string;
  actions?: ReactNode; footer?: ReactNode; className?: string; children?: ReactNode }`
- **Reglas.**
  - **La cifra va a 30 px y no a los 24 de T2.** Es la única desviación de la escala, y es por el
    contexto: acá la cifra convive con un título con icono arriba y una descripción abajo, mientras
    que en `MetricCard` es el encabezado y no compite con nada. A 24 px se leía como un dato más de
    la caja en vez de como la respuesta a la pregunta del título.
  - **`value` es un string ya formateado.** La tarjeta no formatea ni pluraliza: quien la usa sabe
    si eso es una cifra, un par de cifras o un guion.
  - **`actions` es una ranura ciega.** La tarjeta reserva el lugar y lo alinea; qué se dibuja ahí
    —un botón, un icono, un menú— lo decide quien la usa. Es lo que la hace reutilizable en vez de
    ampliable en cada uso nuevo.
  - **La fila del encabezado no envuelve** (`whitespace-nowrap`). Icono, título y acciones son lo
    que fija el ancho mínimo de la tarjeta; dejar que se parta en dos renglones haría que dos
    tarjetas de la misma fila tuvieran encabezados de distinto alto.
  - **El pie no se rinde vacío.** Un `CardFooter` sin contenido le cambia el relleno a la tarjeta
    entera y deja una franja con borde y fondo sin nada escrito, igual que en `SectionCard`.
  - **`CardContent` lleva `flex-1`**: en una fila de tarjetas de igual alto, es lo que mantiene los
    pies alineados contra el piso en vez de a la altura donde termina cada descripción.
- **Qué reemplaza.** El `ChildViews` de `Domains/Show.tsx`, que ya dibujaba esto a mano, y las
  cuatro tarjetas del inicio de SEO.

### 3.5 `DataTable` — la tabla de datos

- **Veredicto sobre las dos tablas: queda `DataTable.tsx`, se borra `data-table.tsx`.** Ver §5.
- **De dónde sale.** `@/components/DataTable` (propia, sobre `ui/table` + TanStack en modo manual).
  Ya cumple RT-09: pagina, ordena y filtra en el servidor, con el estado en la URL.
- **Estructura fija** (la que ya tiene; acá se congela):
  1. Línea de conteo `role="status" aria-live="polite"`: total + unidad + «página X de Y».
  2. Contenedor único desplazable, `max-h-[70vh] overflow-auto overscroll-contain rounded-lg
     border`, con `TableHead` `sticky top-0`.
  3. `<caption className="sr-only">` que nombra la lista **y el filtro vigente**.
  4. Filas.
  5. `<nav aria-label="Paginación">` con Anterior / Siguiente y la posición.
- **Encabezado.** T6 (`text-xs font-medium text-muted-foreground`), alineado como su columna. Si es
  ordenable es un `<a>` de verdad con `aria-sort`, no un manejador de clic.
- **Densidad de fila.** `py-3` estándar, `align-top`. `py-2` sólo si todas las celdas son de una
  línea. Nada de filas de 57 px con un `<details>` adentro, como hoy en V12.
- **Números.** Toda columna numérica: `text-right tabular-nums`, y **su encabezado también a la
  derecha**.
- **Fechas.** Cada fecha en su propia columna, con `DataTimestamp` (RT-01, RT-02). Nunca en un
  `title`.
- **Vacío.** Se delega en `EmptyState` por el prop `empty`. La tabla no escribe su propio texto.
- **Carga.** Filas de `Skeleton` que conservan la altura de lo que había (`data.rows.length || 5`).
  Nunca un spinner encima de la tabla.
- **Paginación.** Servidor, 50 por página, total a la vista (RT-09). No se agrega selector de
  tamaño de página ni paginación numerada.
- **Fila que abre detalle.** **La fila no es clickeable.** El detalle se abre desde un `<Link>` en
  la primera celda —así funcionan Cmd+clic, el clic del medio y el teclado— o desde un
  `DropdownMenu` en la última celda cuando hay dos o más acciones. Un detalle que no merece página
  se abre en un `Sheet` desde un botón con nombre, nunca desde el fondo de la fila.
- **Props.** Los que ya tiene: `{ data, columns, unit, unitSingular?, caption, empty, rowId?,
  error?, loading?, className? }`.
- **Qué reemplaza.** Las tres listas grandes que hoy **no usan tabla** (Cobertura, Lotes, Dominios)
  y las dos que usan `ui/table` a pelo sin paginar (Sesiones, Notificaciones). Cinco de las seis
  listas del producto pasan por acá.

### 3.6 `DescriptionList` / `LabelValue` / `Item` — el par etiqueta/valor

Las fichas de dominio y de lote son todas esto. Tres formas, un solo criterio:

- **`LabelValue`** — un par suelto adentro de otro bloque.
  `{ term: string; value: ReactNode; hint?: string; align?: 'start' | 'end' }`
  `dt` en T6 muted, `dd` en T5, `gap-1`, `wrap-anywhere` en el valor.

  **`align` reemplazó a `numeric`, y el porqué es la regla.** `numeric` describía **qué era el
  dato** y de ahí deducía un layout: quien la marcaba estaba pensando «esto es un número», no
  «quiero esto contra el margen derecho», y terminaba pidiendo sin saberlo una alineación que casi
  nunca era la que la pantalla necesitaba —en una lista de dos columnas deja el término a la
  izquierda, el valor a la derecha y un hueco en el medio que hay que cruzar con la vista para
  aparearlos—. De nueve usos en el producto, **ninguno** la quería. Una prop de presentación dice lo
  que hace; si dice lo que el dato es, se marca sola y rompe donde nadie mira.

  Corolario para cualquier prop nueva de estas piezas: **nombrarla por su efecto, no por el tipo de
  su contenido.**
- **`DescriptionList`** — un grupo de pares de sólo lectura. `<dl>` en grilla.
  `{ items: LabelValueProps[]; columns?: 1 | 2; className?: string }`
  `gap-3` entre pares; en dos columnas, `gap-x-8`.
- **`Item`** (primitiva `@shadcn/item`, **a instalar**) — cuando el par **tiene una acción**:
  `ItemContent` → `ItemTitle` (T4) + `ItemDescription` (T5 muted) · `ItemActions` (derecha).
  Es la fila de lista con botón que hoy redibujan a mano `Settings/Index.tsx:609`,
  `Onboarding/Wizard.tsx:1102` (idénticas, las dos con `divide-y rounded-lg border`), `ApiKeys` y
  `Sessions`.

**Regla.** Un par nunca es una `Card` y nunca lleva borde propio. Un grupo de pares vive adentro de
**una** `Card` o `Section`. Si el grupo pasa de ocho pares, la mitad se va a un `Sheet` o a una
pestaña (§4): no se agranda la ficha.

### 3.7 `StatusBadge` — el badge de estado

- **El problema.** Hay tres badges y **dos formas**: `CoverageStateBadge` dibuja su propio `<span>`
  con `rounded-md border px-2 py-0.5 text-xs`, mientras `BatchStateBadge` y `AccessStateBadge` usan
  `ui/badge`, que es `rounded-4xl h-5`. El mismo concepto con dos radios distintos.
- **La pieza.** Un único `StatusBadge` sobre `ui/badge`, con un juego **cerrado** de cinco tonos:

  | `tone` | Significa | Cómo se ve |
  |---|---|---|
  | `positive` | Google dijo que sí · operativo · completado | verde, relleno tenue |
  | `attention` | Google dijo que no · parcial · falta una acción tuya | ámbar, relleno tenue |
  | `critical` | falló · acceso perdido · revocado | `destructive` |
  | `neutral` | en cola · suspendido | `outline` |
  | `unknown` | RT-03: todavía no preguntamos | **borde punteado, sin relleno, muted, `Clock`** |

- **Estructura fija:** `<Badge variant>` → `<Icon aria-hidden className="size-3" />` → **palabra**.
  Icono primero, palabra después, en ese orden (RT-04). Nunca sólo icono. Nunca sólo color. La
  prueba es la captura en escala de grises: los estados se tienen que seguir distinguiendo.
- **Props.** `{ tone: Tone; icon: LucideIcon; children: string; help?: string; className?: string }`
- **Qué pasa con los tres que ya existen.** `CoverageStateBadge`, `BatchStateBadge` y
  `AccessStateBadge` **conservan su nombre, sus mapas de estado y sus docstrings** —ahí vive el
  conocimiento del dominio, incluido por qué `PARTIAL` tiene silueta propia y por qué `UNKNOWN` no
  es negativo— y pasan a **rendir a través de `StatusBadge`** en vez de dibujar su propio `<span>`.
  Es el cambio más chico que unifica la forma sin perder el razonamiento.
- **Regla.** Un badge de estado en una tabla va **siempre** seguido de su columna de fecha (RT-02).
  El `help` pasa de `title=` a `Tooltip`, porque `title` no se alcanza con el teclado ni existe en
  táctil; pero la **palabra** tiene que leerse sin el tooltip.

### 3.8 `EmptyState` — el estado vacío

- **De dónde sale.** Primitiva `@shadcn/empty` (**a instalar**): `Empty` / `EmptyHeader` /
  `EmptyMedia` / `EmptyTitle` / `EmptyDescription` / `EmptyContent`.
- **Se conserva el nombre `EmptyState` y sus props** (`{ icon, title, description, action }`) para
  que el prop `empty` de `DataTable` y los llamadores no se toquen. Cambia el adentro, no la firma.
- **Estructura.** icono (opcional) → `title` en T4 → `description` en T5 muted `max-w-prose` → una
  acción primaria, y como mucho un enlace secundario.
- **Regla.** Nunca «no hay datos» a secas: dice qué falta y cuál es el paso siguiente. Para alguien
  que recién llega, una tabla vacía y una tabla rota se ven igual.

### 3.8b `RowActions` — el menú de la fila, con juego cerrado

- **Qué es.** El `DropdownMenu` con lo que se puede hacer con un renglón. Reemplaza los botones
  sueltos repartidos por las filas (veintiún «Cerrar» en la lista de sesiones).
- **Dónde va.** **Al principio de la fila, junto a la casilla de selección**, y no al final. En una
  tabla ancha el final está detrás de un desplazamiento horizontal, así que llegar a lo que se puede
  hacer con una fila costaba arrastrar la tabla hasta el borde y volver. La columna la arma
  `ClientDataTable`, no la vista: es la posición la que tiene que ser igual en todas las tablas, y
  una columna que cada pantalla declara termina en un sitio distinto en cada una.
- **El disparador** es `MoreVertical` —tres puntos apilados—, `variant="ghost" size="icon-sm"`, con
  el nombre de la fila en un `sr-only`: «Acciones de la sesión en Chrome iniciada el 3 de marzo».
  Veinte botones llamados «Acciones» obligan a recorrer la tabla celda por celda.
- **Regla dura — el juego de acciones es cerrado.** Una vista **no elige el icono, ni el rótulo por
  omisión, ni la posición** de una entrada: elige si esa fila la tiene y qué hace. El registro vive
  en `RowActions.tsx`:

  | `kind` | Icono | Rótulo por omisión | Destructiva |
  |---|---|---|---|
  | `detail` | `PanelRightOpen` | `rowAction.detail` | |
  | `open` | `ArrowUpRight` | `rowAction.open` | |
  | `list` | `List` | `rowAction.list` | |
  | `copy` | `Copy` | `rowAction.copy` | |
  | `download` | `Download` | `rowAction.download` | |
  | `rename` | `Pencil` | `rowAction.rename` | |
  | `retry` | `RotateCw` | `rowAction.retry` | |
  | `revoke` | `Ban` | `rowAction.revoke` | sí |
  | `close` | `XCircle` | `rowAction.close` | sí |
  | `delete` | `Trash2` | `rowAction.delete` | sí |

  El **orden del menú es el de la tabla**, no el que escriba la vista: dos pantallas que ofrecen lo
  mismo lo tienen que ofrecer en la misma posición, o el menú deja de reconocerse de un vistazo.
  Si hace falta una acción que no está, se agrega acá y queda para todas. Eso es a propósito: sumar
  una fila a este registro es una decisión de producto; escribir un icono suelto en una vista no lo
  era.
- **La vista aporta la funcionalidad**, entrada por entrada: `href` (se rinde como `<Link>` de
  verdad, así que Cmd+clic y «abrir en otra pestaña» funcionan), `onSelect`, `disabled` y `confirm`.
- **Regla dura — una acción sin nada que hacer no se dibuja.** Declarada sin `href` y sin
  `onSelect`, no lleva a ningún lado ni hace nada, y una entrada inerte es peor que su ausencia:
  se elige, no pasa nada, y no queda claro si falló o si nunca hizo algo (RT-07). Es lo que permite
  que una vista arme el mapa de una sola forma para todas sus filas y deje en blanco lo que esa fila
  no ofrece. Una fila sin ninguna acción **no dibuja el disparador**.
- **`label` propio, sólo para nombrar el objeto.** «Ver la lista completa» no distingue la lista de
  lotes de un dominio de la de otro, así que ahí la vista pisa el rótulo. Si la entrada es el verbo
  a secas, se deja el del registro y así las nueve tablas dicen lo mismo.
- **Lo destructivo va último, detrás de un `DropdownMenuSeparator`**, y siempre a través de
  `ConfirmDestructive`. La confirmación **no se monta adentro de la fila**: al confirmar, la fila
  desaparece de la respuesta del servidor y el diálogo se desmontaría con ella, dejando el foco en
  ningún lado y el `pointer-events: none` de Radix puesto sobre una página que ya no tiene quién lo
  saque.

### 3.9 Carga — `Skeleton` + `Spinner`

- **`Skeleton`** (instalado) para contenido de forma conocida: filas de tabla, tarjetas, el valor de
  una métrica. **Conserva la altura de lo que reemplaza**; el diseño no salta.
- **`Spinner`** (primitiva `@shadcn/spinner`, **a instalar**) **sólo adentro de un botón o de un
  badge**, para una acción en vuelo. Nunca un spinner de página entera, nunca uno encima de una
  tabla.
- Botón en vuelo: `<Spinner />` + gerundio + `…` («Guardando…», «Revocando…»). El botón queda
  habilitado hasta que arranca la petición y lleva `aria-busy`.
- Una `MetricCard` cargando: `Skeleton` de `h-8 w-24` en el lugar del valor; la etiqueta se queda.
- Menos de 300 ms no muestra nada: un parpadeo es peor que la espera.

### 3.10 Error — `ServerErrorNotice` + `Field` / `FieldError`

- **`ServerErrorNotice`** (existe, se conserva tal cual) es el mapa cerrado de RT-08. Va **arriba de
  la `Section` que falló**, nunca en un toast. Cada vista suma sus códigos por `codes`; ninguna
  reescribe los del mapa base.
- **Errores de validación**: junto al campo (RT-08). Se adopta la primitiva `field` —que está
  instalada y **sin usar en ninguna página**— para que el cuarteto etiqueta + campo + ayuda + error
  deje de redibujarse en cada formulario: `FieldSet` / `FieldGroup` / `Field` / `FieldLabel` /
  `FieldDescription` / `FieldError`. `FieldError.tsx` y `fieldErrorProps()` siguen siendo la fuente
  del `aria-invalid` / `aria-describedby`.
- **`sonner`** (instalado, sin usar): queda reservado para confirmar una acción que **no dejó
  rastro en pantalla**, y aun así se prefiere la confirmación en el lugar, como hace `CopyButton`
  (RT-11). Ningún error va en un toast, nunca.

### 3.11 `DangerZone` — la barra de acciones peligrosas

- **`ConfirmDestructive`** (existe) ya hace todo bien: `AlertDialog`, foco inicial en «Cancelar», no
  cierra por clic afuera, se queda abierto mientras la acción corre. **No se toca y no se
  reemplaza.** Ninguna página importa `alert-dialog` directamente.
- **Lo que falta es la ubicación.** Se agrega `DangerZone`: una `Section` con `tone="destructive"`,
  **al pie** de la página o de la sección dueña, con un `Item` por acción — nombre en T4,
  consecuencia en T5 muted, y el `Button variant="destructive"` a la derecha, que es el `trigger`
  de un `ConfirmDestructive`.
- **Props.** `{ title: string; description?: string; children: ReactNode }`
- **Reglas.** Ninguna acción destructiva en el slot `actions` del encabezado. Ninguna en la primera
  celda de una tabla. Ninguna sin `ConfirmDestructive`. Ninguna acción destructiva en lote sin la
  **cantidad** en el título de la confirmación («Cerrar las 23 sesiones restantes»). Y RT-07: nada
  apagado con «Próximamente».

### 3.11.b `Alert` contra `Card` — dos piezas que no se pueden parecer

**El problema.** El registro dibujaba `Alert` con `bg-card` y el mismo borde que `Card`: la **misma
superficie**. Las dos piezas se distinguían sólo por el icono y por el tamaño del título, y su
variante destructiva apenas cambiaba el color del texto. Puestas una debajo de la otra —como pasa en
la ficha de dominio, donde el aviso «Falta autorizarnos» queda arriba de la tarjeta «Acceso a la
propiedad»— no se lee cuál es la condición y cuál es el contenido, ni cuál de las dos importa más.

**La separación es de superficie, no de detalle.**

| | `Alert` | `Card` |
|---|---|---|
| Qué es | Una **condición**: algo que pasa ahora y que puede dejar de pasar | **Contenido** de la página, que está siempre |
| Superficie | **Teñida** según su tono | Neutra (`bg-card` + `ring-1`) |
| Icono | Obligatorio | No lleva |
| Título | T4 | T3 |
| Dónde | Arriba de la `Section` a la que le habla | Adentro de una `Section` |

**Los tonos son los mismos cuatro de `StatusBadge`, con los mismos valores**: `default` (neutro
tenue), `positive`, `attention` y `critical` —`destructive` se conserva como su nombre, porque ya lo
usan varias vistas—. Que un estado y el aviso que habla de ese estado compartan paleta es lo que hace
que se lean como la misma familia; dos paletas que casi coinciden es peor que una sola.

**Reglas.**

- Un `Alert` **nunca** lleva `bg-card` ni el borde de una tarjeta, y una `Card` nunca lleva fondo
  teñido. Si hacen falta las dos en la misma pantalla, tienen que distinguirse desde la miniatura.
- **El tono se elige por lo que la condición le pide a la persona**, no por cuánto se quiere llamar
  la atención: `attention` si falta una acción suya, `critical` si algo se rompió, `positive` si algo
  quedó resuelto, `default` si sólo informa.
- **Dos avisos seguidos que dicen lo mismo son uno solo.** Si un `Alert` anuncia una condición y la
  `Card` de abajo existe para resolverla, o el aviso se disuelve dentro de la tarjeta o la tarjeta no
  hacía falta.

### 3.12 Movimiento — aplica a todas las piezas

Esto es una herramienta de trabajo que se usa muchas veces por día. La regla de fondo: **cuanto más
seguido se ve una transición, menos tiene que durar, y a partir de cierta frecuencia no existe.**

- Sólo se animan `transform` y `opacity`. **Nunca `transition: all`** (es grep-eable).
- Entrada `ease-out`, salida más rápida que la entrada. Curva: `cubic-bezier(0.23, 1, 0.32, 1)`.
- Duraciones: presión de un botón 120 ms · tooltip y popover 150 ms · dropdown, sheet y dialog
  200 ms. **Nada supera los 250 ms.**
- Popover, dropdown y tooltip escalan **desde su disparador**
  (`transform-origin: var(--radix-popover-content-transform-origin)`). El `Dialog` se queda
  centrado.
- Nunca entrar desde `scale(0)`: `scale(0.97)` + `opacity: 0`.
- Todo lo presionable lleva `active:scale-[0.97]`.
- **Cambiar de página, ordenar una columna o aplicar un filtro no se anima.** Son navegaciones
  repetidas y alcanzables por teclado; el único aviso es el intercambio por el esqueleto.
- No hay transiciones de página ni entradas escalonadas.
- `motion-reduce:transition-none` / `motion-reduce:animate-none` en todo lo que se mueva. El
  repositorio ya lo hace en varios lugares; ahora es obligatorio.

---

## 4. Cuándo usar cada patrón de revelación progresiva

### El árbol de decisión (se recorre en orden y se para en el primer sí)

1. ¿Es destructivo o irreversible? → **AlertDialog** (`ConfirmDestructive`). Se termina acá.
2. ¿Tiene identidad propia, URL propia, o alguien puede llegar desde afuera (un enlace, una
   notificación, la barra lateral)? → **Página propia**.
3. ¿Es un formulario? ≤ 5 campos y bloquea el flujo → **Dialog**. > 5 campos → **página propia** o
   una `Section` en la misma página. **Nunca un formulario largo en un Sheet.**
4. ¿Es el detalle de **una fila** y hay que volver a la lista sin perderla? → **Sheet**.
4b. ¿Es una cifra o un dato que se abre para leer? → **Dialog**, siempre que lo de adentro no se
   navegue solo (ver las notas al pie de la tabla) y no haya que mirar lo de atrás al mismo tiempo.
5. ¿Son 2 a 5 vistas **hermanas** del mismo objeto, excluyentes, entre las que se va y se vuelve?
   → **Tabs**, con el estado en la URL (`?tab=`).
6. ¿Son 3 o más bloques secundarios **del mismo tipo**? → **Accordion**. ¿Uno solo? → **Collapsible**.
7. ¿Es un control chico anclado a un disparador? → **Popover**.
8. ¿Es la previsualización de una entidad enlazada? → **HoverCard**.
9. ¿Es una ayuda de ≤ 12 palabras sobre un control que ya tiene rótulo visible? → **Tooltip**.
10. ¿Son 2 o más acciones sobre una fila? → **DropdownMenu**.
11. Ninguna de las anteriores → **se queda en la página**. No se inventa un contenedor.

### La tabla

| Patrón | Cuándo sí | Cuándo no | Primitiva | Dónde en este producto |
|---|---|---|---|---|
| **Página propia** | Identidad y URL propias; >8 datos o tabla propia | Para algo a lo que sólo se llega desde una fila | `<Link>` | Cobertura, ficha de lote, ficha de dominio |
| **Tabs** | 2–5 vistas hermanas del mismo objeto, excluyentes | Pasos de un recorrido; filtros; una pestaña casi siempre vacía | `tabs` ✅ | Ficha de dominio: Cobertura · Sitemaps · Lotes · Configuración |
| **Sheet** | Detalle de una fila; hay que volver a la lista; lectura, ≤1 acción | Formulario >4 campos; destructivo; algo que merece URL | `sheet` ✅ | Detalle de una URL en Cobertura (hoy el único uso correcto); el agente declarado en Sesiones |
| **Drawer** | **No es una decisión aparte**: es la presentación del `Sheet` abajo de `md` | Como alternativa al Sheet en escritorio | `drawer` ✅ | Sólo adentro de `Sheet` |
| **Dialog** | Tarea corta que bloquea: ≤5 campos, una acción primaria. **También leer**: una cifra que se abre, una lista acotada, la evidencia de un pedido | Contenido con controles de navegación propios (paginado, orden por columna, filtros del servidor); si hay que mirar lo de atrás mientras se lo lee; anidar diálogos | `dialog` ⛔ instalar | Encolar inspección manual; crear/renombrar clave de API; el cupo del día; las direcciones detrás de una cifra |
| **AlertDialog** | Confirmación destructiva o irreversible. **Siempre** | Cualquier otra cosa | `alert-dialog` ✅ vía `ConfirmDestructive` | Revocar clave, cerrar sesiones, borrar dominio |
| **Accordion** | 3+ bloques secundarios del mismo tipo; se lee uno y se sigue | El contenido principal de la vista; navegación; más de un nivel | `accordion` ⛔ instalar | Los 7 pasos del recorrido; fallas agrupadas por motivo en un lote; el bloque de filtros de Cobertura |
| **Collapsible** | Un solo bloque secundario que se pliega | Tres o más (eso es acordeón) | `collapsible` ⛔ instalar | «Detalle técnico» de un error |
| **Popover** | Control chico anclado: filtro, rango de fechas, elector de columnas. Es interactivo | Ayuda de sólo lectura; algo que hay que ver mientras se hace otra cosa | `popover` ⛔ instalar | Rango de fechas de Cobertura; filtros que no entran en chips |
| **Tooltip** | Ayuda de ≤12 palabras sobre un control ya rotulado | **Como único lugar donde vive un dato** (RT-02); en táctil | `tooltip` ✅ | El `help` de los tres badges de estado |
| **HoverCard** | Previsualización rica (2–6 datos) de una entidad enlazada, sólo lectura | Si lleva un control adentro; en táctil | `hover-card` ⛔ instalar | El lote detrás de «Ver el lote 407c4293»; el dominio detrás de un hostname |
| **DropdownMenu** | 2+ acciones sobre una fila o un objeto | Una sola acción (eso es un botón); navegación principal | `dropdown-menu` ✅ | Acciones por fila en Sesiones, Dominios, Claves de API, Sitemaps |

Notas que cierran discusiones antes de que empiecen:

- **Un diálogo sí es para leer, y hasta dónde llega se mide por los controles.** La redacción
  anterior decía «nunca para leer; nunca con una tabla adentro», y las dos mitades estaban mal: un
  diálogo que muestra el cupo del día o las cuatro direcciones que se cayeron del índice es
  exactamente el lugar correcto. Lo que no entra no es «una lista», es **una lista que se navega
  sola**.

  El techo, y es un techo y no un piso —nada de esto hay que ponerlo si la vista no lo pide—:

  | Entra en un diálogo | Necesita vista propia |
  |---|---|
  | Buscador de texto sobre las filas que ya están | Paginado, del servidor o del cliente |
  | Uno o dos selectores de un eje cerrado | Orden por columna; varios ejes combinados |
  | Todo resuelto **en el cliente**, sobre datos que ya viajaron | Cualquier ida al servidor para recortar |

  Pasado eso ya es una pantalla y le corresponde una URL. La tabla de direcciones de un lote de
  indexación —con su buscador, su orden por cuatro columnas y su paginador— es el ejemplo de lo que
  queda afuera.

- **El segundo corte: ¿hay que mirar lo de atrás mientras se lee?** Si comparar con lo que quedó
  debajo es parte de la tarea, es `Sheet` o página, no diálogo. Es lo que deja los cinco paneles del
  tablero como `Sheet`: se abren para cruzar el agregado con las tarjetas que lo resumen.

- **`Drawer` no compite con `Sheet`.** Un solo componente: `Sheet` en `md+`, `Drawer` abajo. Que
  cada agente elija uno de los dos es exactamente la divergencia que este contrato existe para
  evitar.
- En un `DropdownMenu`, el ítem destructivo va **último**, después de un `DropdownMenuSeparator`, y
  abre un `ConfirmDestructive`.
- **Todo estado de revelación va a la URL**: pestaña abierta, panel abierto, página, orden, filtro.
  Es lo que hace que la vista sea enlazable y que el botón Atrás funcione. `DataTable` ya lo hace;
  las pestañas y los sheets tienen que hacerlo igual.
- Un `<details>` a mano sólo sobrevive en el detalle técnico de `ServerErrorNotice`. No crece de
  ahí: V12 tiene 21 `<details>` sueltos en una tabla y por eso las filas miden 57 px.

---

## 5. Qué primitivas de shadcn adoptar

Registro: `@shadcn`, estilo `radix-nova`, base `neutral`, iconos `lucide`.

### Instaladas y sin usar que **sí** hay que usar

| Primitiva | Para qué |
|---|---|
| `field` | **La más desaprovechada.** El cuarteto etiqueta + campo + ayuda + error de todos los formularios |
| `tabs` | Ficha de dominio, ficha de lote, Configuración. Con `?tab=` en la URL |
| `dropdown-menu` | Acciones por fila (hoy, 21 botones «Cerrar» sueltos en V12) |
| `separator` | Entre bloques de una `Card` donde un gap no alcanza (el hueco de la tarjeta «Configuración» en V6) |
| `tooltip` | El `help` de los badges, que hoy vive en un `title=` inalcanzable por teclado |
| `select` | Donde hay más de 5 opciones de filtro u orden |
| `toggle-group` | Chips de filtro con ≤5 opciones (Cobertura ya lo hace bien: es el patrón a copiar) |
| `drawer` | Sólo como presentación del `Sheet` abajo de `md` |
| `progress` | Barras de una sola razón. `BatchProgress` y `QuotaMeter` quedan con su barra propia —las rayas de lo fallido y los dos bolsillos son RT-04 y ya está argumentado en el código—, y son **las únicas dos excepciones**: nadie más dibuja una barra a mano |
| `skeleton` | Toda la carga |
| `alert-dialog` | Sólo a través de `ConfirmDestructive`; ninguna página lo importa |
| `sonner` | Reservado (§3.10). Casi nunca |

### Instaladas que **no** se usan, a propósito

- **`breadcrumb`** — no. El encabezado es una franja de una línea y la barra lateral ya dice dónde
  estás. Queda descartada por escrito para que nadie la agregue «por completitud».
- **`avatar`** — sólo en `nav-user`, que es armazón intocable.
- **`chart`** / `recharts` — sólo el `chart-area-interactive` del tablero. **No se agregan
  gráficos**: un apilado que meta `UNKNOWN` adentro es RT-03 dibujado al revés.
- **`checkbox`** — no hay selección múltiple en ninguna vista; no se inventa una.

### Faltan instalar

```
npx shadcn@latest add @shadcn/empty @shadcn/item @shadcn/spinner @shadcn/accordion @shadcn/collapsible @shadcn/dialog @shadcn/popover @shadcn/hover-card @shadcn/button-group @shadcn/input-group @shadcn/kbd
```

| Primitiva | Para qué | Prioridad |
|---|---|---|
| `empty` | Adentro de `EmptyState` | alta |
| `item` | Filas etiqueta/valor con acción; `DangerZone`; las dos listas idénticas dibujadas a mano en Settings y el Wizard | alta |
| `spinner` | Botones en vuelo | alta |
| `accordion` + `collapsible` | §4 | alta |
| `dialog` | Formularios cortos que bloquean | alta |
| `popover` | Controles chicos anclados | media |
| `hover-card` | Previsualización de entidad enlazada | media |
| `button-group` | El par de botones del encabezado («Sitemaps» + «Consultar ahora») y los pares de la página, para que entren en una línea | media |
| `input-group` | El campo de búsqueda con su botón (Cobertura hoy lo arma a mano) | media |
| `kbd` | Atajo de la búsqueda, si se agrega | baja |

**No se instalan:** `pagination` (`DataTable` ya trae anterior/siguiente + total, y a 50 por página
la numerada es superficie sin ganancia) ni `scroll-area` (`DataTable` usa `overflow-auto
overscroll-contain`, que es lo correcto).

### Veredicto sobre las dos tablas

**Queda `frontend/components/DataTable.tsx`. Se borra `frontend/components/data-table.tsx`.**

- `data-table.tsx` (820 líneas) es la demo del bloque `dashboard-01`: pagina y ordena **en el
  cliente**, lo que viola RT-09 de entrada, y trae arrastrar-para-reordenar que este producto no
  necesita. **No lo importa ninguna página** (verificado por búsqueda en `frontend/`).
- `DataTable.tsx` (468 líneas) ya hace lo que el contrato pide: servidor, estado en la URL,
  `aria-sort`, encabezado fijo, esqueleto que conserva la altura, `caption` accesible, total
  anunciado por región viva.
- Borrarlo además libera `@dnd-kit/core`, `@dnd-kit/modifiers`, `@dnd-kit/sortable` y
  `@dnd-kit/utilities`, que son sus únicas usuarias.

---

## 6. Reglas de consistencia verificables

Lo que un revisor comprueba mirando una pantalla o corriendo una búsqueda. Cada una es un sí o un
no, sin criterio de por medio.

**Tipografía**

1. Ninguna página escribe `<h1>`; hay exactamente uno por pantalla y sale de `SiteHeader`.
   **Excepción declarada, y la única: `frontend/pages/Login.tsx`.** Es la única vista sin sesión, así
   que no usa `AppLayout` y no hay ningún armazón que le ponga el `<h1>` —no hay barra lateral, ni
   encabezado, ni avisos de nivel cuenta, porque todavía no hay cuenta—. Es además la única pantalla
   donde el producto se nombra a sí mismo, y sin ese `<h1>` quedaría sin ningún encabezado, que es la
   regla 2 rota para arreglar la 1. La excepción está escrita acá para que nadie la «arregle»
   moviéndole el título a un `<p>` o metiéndole el armazón.
2. Todo `h2` es T3 y todo `h3` es T4. No hay `h4` ni más abajo. Ninguna vista tiene cero
   encabezados.
3. `grep -r "text-lg\|text-xl\|text-3xl\|font-bold\|text-\[" frontend/pages frontend/components`
   (excluyendo `ui/`) da **cero**.
4. `font-semibold` aparece sólo en T2.
5. Ningún dato vive únicamente en un `title=`.

**Espacio y forma**

6. `grep -r "rounded-lg border\|rounded-xl border" frontend/pages/` da **cero**: ninguna página
   define su propio borde de tarjeta.
7. Ninguna `Card` lleva `p-*` propio.
8. Ninguna `Card` adentro de otra `Card`.
9. Ninguna página envuelve a sus hijos en un contenedor con un `gap` distinto del `gap-6` de
   `<main>`.
10. Todo párrafo explicativo lleva `max-w-prose`. A 1440 px ninguna línea de texto corrido pasa de
    ~75 caracteres.
11. En una fila de tarjetas, todas miden lo mismo de alto y sus acciones quedan a la misma altura.
12. Dos piezas del mismo tipo, lado a lado, tienen el mismo ancho.

**Datos y estados**

13. Toda lista que pueda pasar de 50 filas se rinde con `DataTable`, pagina en el servidor y
    muestra el total (RT-09).
14. Toda columna numérica va `text-right tabular-nums`, encabezado incluido.
15. Todo estado se distingue con icono + palabra en una captura en escala de grises (RT-04).
16. Todo estado de indexación tiene su fecha en **columna propia** (RT-02).
17. Todo porcentaje muestra su denominador y `UNKNOWN` no entra en ninguno ni en ninguna barra
    apilada (RT-03).
18. Toda vista con datos fechados termina en `TimezoneFootnote` (RT-01).
19. Ninguna fila de tabla es clickeable entera: el detalle se abre desde un `<Link>` o un
    `DropdownMenu`.

**Acciones y errores**

20. Ninguna acción destructiva sin `ConfirmDestructive`, y ninguna en el slot `actions` del
    encabezado.
21. Lo que se pasa a `actions` entra en **una línea**: hasta dos botones o un `ButtonGroup`. Sin
    badges, sin fechas, sin explicaciones.
22. Ningún error de validación en un toast; ningún error de servidor fuera de `ServerErrorNotice`.
23. Ningún control deshabilitado con «Próximamente», ningún plan, precio ni límite de plan en
    pantalla (RT-07).
24. Ningún botón `size="sm"` en la misma fila que uno `size="default"`.

**Movimiento**

25. `grep -r "transition-all" frontend/` da **cero**.
26. Todo lo que se mueve tiene su variante `motion-reduce:`.
27. Ninguna transición supera los 250 ms. Ordenar, paginar y filtrar no se animan.

**Higiene**

28. `frontend/components/data-table.tsx` no existe.
29. Ninguna página importa `alert-dialog` directamente.
30. Ningún nombre de persona real en el código, comentarios, `TODO` ni mensajes de commit.
