## V7 · Cobertura — `/domains/{id}/coverage` — [`frontend/pages/Coverage/Index.tsx`](frontend/pages/Coverage/Index.tsx)

**Veredicto**: rehacer — **severidad** 5/5
**El problema en una línea**: la vista más valiosa del producto pone su tabla —lo único que contesta
«qué sabe Google de mi sitio»— a 1.398 px del tope, así que al llegar no se ve **ninguna** fila, y
lo que ocupa esos 1.398 px es, en más de la mitad, la misma información contada dos veces.

### Qué pasa hoy

- **El fold no muestra una sola URL.** Midiendo sobre `v7-coverage.png`: la bajada está en y≈90, el
  titular de honestidad en y≈138, el resumen de dos bloques ocupa de y≈233 a y≈608, la actividad de
  consulta de y≈630 a y≈755, los filtros de y≈775 a y≈995, el panel de exportación de y≈1020 a
  y≈1280, la línea de conteo en y≈1300, el encabezado de la tabla en y≈1340 y **la primera fila de
  datos en y≈1398**: 498 px por debajo del corte de 900. El 16 % medido no describe una vista densa,
  describe una vista con la respuesta enterrada.
  (De paso: el alto de 5.567 px es casi con seguridad un artefacto. El contenido visible termina en
  y≈2130 y abajo hay 3,4 k px en blanco; el único `vh` de la página es el `max-h-[70vh]` de
  `DataTable.tsx:309`, que en una captura de página completa se mide contra la página completa. La
  cifra a perseguir no es el alto: es **cuántas filas se ven al llegar**, que hoy son cero.)
- **El reparto por estado está escrito dos veces, a 400 px de distancia.** `CoverageSummary.tsx:199-230`
  dibuja una tabla de sólo lectura con «Indexada 62 · 67,4 %», «Rastreada, sin indexar 14 · 15,2 %»…
  y `Index.tsx:829-843` dibuja los mismos números como botonera: «Indexada (62)», «Rastreada, sin
  indexar (14)»… Uno es un dato inerte y el otro es el control que lo filtra. Son el mismo
  conocimiento, y el que no es control es el que sobra.
- **De los 19 botones, 14 son fichas de filtro.** Contando sobre la captura: 4 de «Recorte», 3 de «En
  el sitemap», 7 de «Estado informado por Google». Los cinco restantes son Sitemaps, Consultar ahora,
  Buscar, Exportar y Descargar. No hay diecinueve acciones: hay tres ejes de filtrado dibujados como
  `Button asChild` (`Index.tsx:905-942`), que es exactamente lo que un `ToggleGroup` y un `Popover`
  resuelven sin ocupar tres renglones con sus rótulos.
- **De las «25 tarjetas», la mayoría son badges.** `CoverageStateBadge.tsx:123` dibuja su `<span>` con
  `rounded-md border px-2 py-0.5`; `Index.tsx:620`, `:995` y `:1199` y `CoverageSummary.tsx:148` y
  `:240` dibujan sus secciones con `rounded-lg border p-4`; y `ui/card` usa `rounded-xl ring-1`.
  Tres siluetas distintas para tres conceptos distintos, y ninguna es la primitiva. Cualquier
  auditoría estructural cuenta el badge de una fila y la sección de exportación como el mismo objeto,
  porque **materialmente lo son**.
- **La tabla desperdicia el 45 % de su ancho en columnas vacías.** En las siete filas visibles,
  «Motivo declarado por Google» muestra «—» en siete, «Canónica de Google» muestra «—» en siete y
  «Último rastreo de Google» muestra «No lo informó» en siete. Con siete columnas en ~1.080 px, la
  columna de URL queda en ~200 px y `https://ejemplo.com/nota/0001` se parte en dos renglones — y el
  badge «Indexada» **se parte a mitad de palabra**, «Indexad / a», visible en la captura. Una fila
  mide ~85 px por eso, más el enlace «Ver el historial» que cuelga debajo de la dirección.
- **Quien llega desde un aviso no se entera de que está mirando un recorte.**
  `apps/notifications/services.py:230` arma `?state=FETCH_ERROR` y `?state=BLOCKED_ROBOTS`, así que
  las notificaciones de errores de descarga y de bloqueo por robots aterrizan acá con el filtro
  puesto. Lo único que lo dice es: la ficha resaltada (y≈919), la línea de conteo (y≈1300), «Quitar
  los filtros» (y≈1000) y el `<caption>` que arma `filterDescription()` (`Index.tsx:700`) — que es
  `sr-only`. Sobre el fold: nada. Y el resumen de arriba sigue diciendo «92 de 120», que es el
  dominio entero. Alguien que viene de «Google no pudo descargar 12 URLs» ve un tablero del sitio
  completo y tiene que bajar 1.000 px para descubrir que la tabla ya está filtrada.

### En qué orden debería mirarse

1. **Cuántas de mis URLs tienen dato de Google, y de cuándo es ese dato.** Es la línea de honestidad
   del `ux-checklist` y no se negocia: sin el denominador, todo lo de abajo se lee mal.
2. **Cómo se reparten esas URLs entre las tres familias de RT-03**: dato positivo, dato negativo, sin
   dato. Tres cifras, no diez.
3. **Qué recorte estoy mirando** —y si llegué con uno ya puesto—, y cómo lo cambio.
4. **Las URLs.** Con su estado, su fecha y el motivo crudo; a partir de la fila 1, no de la 0.
5. **Una URL en particular**: su historial completo, sin perder la lista ni el filtro.
6. Lo que sólo importa cuando importa: la exportación, el lote en curso, el cupo que se va a gastar.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `<h2 text-xl>` «92 de 120 URLs tienen dato…» (`CoverageSummary.tsx:107`) | Primer ítem de `PageIntro` | `PageIntro` | `text-xl` está prohibido y T2 es una cifra, no una oración. Como ítem de `PageIntro` la línea de honestidad sigue siendo lo primero que se lee, en una línea en vez de en un titular de 28 px |
| «Los datos van del … al …» (`CoverageSummary.tsx:126-136`) | Segundo ítem de `PageIntro` | `PageIntro` + `DataTimestamp` | Es la otra mitad de la misma frase; separarlas fue lo que las hizo ocupar dos renglones |
| Bloque «Con dato de Google» + bloque «Sin consultar todavía» en grilla `2fr/1fr` (`CoverageSummary.tsx:147-257`) | Fila de **3 `MetricCard size="sm"` de igual ancho** | `MetricCard`, la tercera con `tone="unknown"` | El contrato prohíbe el reparto asimétrico entre dos piezas del mismo tipo; y las tres tarjetas son exactamente las tres familias de RT-03 |
| Barra apilada verde/ámbar dibujada a mano (`CoverageSummary.tsx:175-197`) | **Se borra**; sus dos cifras son las tarjetas «Indexadas» y «Sin indexar» | — | El contrato deja sólo dos barras a mano en todo el producto, `BatchProgress` y `QuotaMeter`. Una tercera, y encima apilada, es la que un descuido convierte en RT-03 al revés |
| Tabla de reparto por estado (`CoverageSummary.tsx:199-230`) | Contenido del `Popover` «Estado», fusionada con la botonera | `Popover` + `CoverageStateList` | Es el mismo dato que las diez fichas. Fusionadas quedan diez filas seleccionables con su cuenta y su porcentaje sobre 92, y desaparece la duplicación |
| Estimación del ciclo, 2 líneas (`CoverageSummary.tsx:139-144`) | `note` de la tarjeta «Sin consultar todavía» | `MetricCard` | Contesta «¿cuándo van a tener dato esas 28?». Pegada a esa cifra explica algo; suelta arriba es prosa |
| Mitad derecha de `InspectActivity` — «Los datos más recientes salieron de este lote» (`Index.tsx:653-684`) | Tercer ítem de `PageIntro` | `BatchProvenance` = `<Link>` + `HoverCard` | Es procedencia, no actividad. Tres renglones se vuelven un enlace; el detalle (estado, origen, procesadas, fecha) vive en el hover y la ficha completa está a un clic |
| Mitad izquierda de `InspectActivity` — «Consultando a Google» + `BatchProgress` (`Index.tsx:622-651`) | `Card size="sm"` **condicional**, bajo la fila de métricas | `Card` + `BatchProgress` | Mientras un lote corre es lo más importante de la pantalla; el resto del tiempo tiene que medir 0 px, y hoy la sección se rinde igual |
| Párrafo de `retired` (`Index.tsx:465-472`, 4 renglones) | Sufijo T6 de la línea de conteo de la tabla | texto | El desajuste entre 120 y el total de la tabla sólo se puede comparar ahí. «148 URLs · página 1 de 3 — incluye 28 que ya no están en ningún sitemap»: un renglón en lugar de cuatro, en el único lugar donde hace falta |
| `Filters`, tres grupos con rótulo propio en 3 renglones (`Index.tsx:737-942`) | **Una línea** de barra de filtros | `FilterBar` + `FilterToggleGroup` + `FilterPopover` | Ver «Los filtros», abajo |
| Nota «“No indexadas” no incluye las 28 sin consultar» (`Index.tsx:824-827`) | Adentro del `Popover` «Estado», encabezando el grupo «Sin dato» | `Popover` + `Separator` | El momento en que alguien puede malinterpretar «no indexadas» es cuando lo está por tocar, y ahora lo toca ahí |
| `ExportPanel` entero, 260 px permanentes (`Index.tsx:953-1051`) | Un botón en el slot `actions` de la `Section` de la tabla | `ExportAction` (+ `Dialog` sobre el umbral) | Exportar es una acción sobre el recorte visible; sus tres párrafos explicativos son la letra del `Dialog` que FR-058 exige **antes** de confirmar, no un cartel permanente |
| `ExportStatus` (`Index.tsx:1067-1130`) | `Card size="sm"` **condicional**, al pie de la tabla | `ExportStatusCard` | RT-11 pide que el pedido quede escrito; no pide que ocupe lugar cuando no hay ninguno |
| Columnas «Canónica de Google» y «Último rastreo de Google» | Al `Sheet`, como `DescriptionList` del estado vigente | `Sheet` + `DescriptionList` | Muestran «—» y «No lo informó» en las siete filas visibles y se comen ~320 px que la columna de URL necesita. En el `Sheet` ya están (`Index.tsx:1258-1271`); en el CSV siguen estando |
| Enlace «Ver el historial» debajo de cada dirección (`Index.tsx:207-221`) | La dirección **es** el enlace | `<Link>` en la primera celda | Contrato §3.5: el detalle se abre desde un `<Link>` en la primera celda. Saca un renglón de las 50 filas de cada página |
| Dos párrafos con el mismo disclaimer de RT-05 (`CoverageSummary.tsx:141-143` e `Index.tsx:527-530`) | **Uno solo**, al pie, antes de `TimezoneFootnote` | texto | Es la misma advertencia escrita dos veces en la misma pantalla |
| `inspect.post` directo, sin confirmación ni cupo (`Index.tsx:379-389`) | `QuotaCostDialog` con el `QuotaMeter` adentro | `Dialog` | Ver «El presupuesto de cuota», abajo |

#### Los diez estados, resueltos en tres altitudes

RT-03 pide tres familias; la vista tiene diez valores. La salida no es elegir entre tres y diez: es
que cada altitud muestre lo que a esa altitud sirve.

**Arriba — tres `MetricCard size="sm"` de igual ancho.** Nada de diez.

| Tarjeta | `value` (T2) | `denominator` (`CardAction`) | `note` | `tone` |
|---|---|---|---|---|
| Indexadas | `62` | «de 92 con dato» | «67,4 % de las URLs con dato de Google.» | `default` |
| Sin indexar | `30` | «de 92 con dato» | «Ocho motivos distintos. El desglose está en el filtro por estado.» | `default` |
| Sin consultar todavía | `28` | «de 120 URLs» | «Todavía no le preguntamos a Google. No significa que no estén indexadas. Al ritmo de 1.800 consultas por día, una vuelta a las 120 lleva un día.» | **`unknown`** |

Las dos primeras usan `with_data` como denominador y lo dicen; la tercera usa el total y lo dice.
`UNKNOWN` no entra en ningún porcentaje y su tarjeta es la única con anillo punteado, sin relleno,
muted y `Clock` —la misma familia que su badge—. Y su `CardFooter` **no lleva fecha**, porque no hay
ninguna: esa ausencia es la afirmación, no un olvido. Las otras dos llevan `DataTimestamp` del cierre
de la ventana.

**Al medio — el `Popover` «Estado», que es a la vez el desglose y el filtro.** Su contenido es
`CoverageStateList`: los diez estados agrupados en las tres familias, cada uno con su `StatusBadge`
(icono + palabra, RT-04), su cuenta y su porcentaje sobre las 92. Encabezado del grupo: «Sobre las 92
URLs con dato». Debajo de un `Separator`, el grupo «Sin dato» con `UNKNOWN`: cuenta, **sin
porcentaje**, y la nota «De éstas no sabemos nada todavía». Cada fila es un `<Link>` de verdad que
pisa `state` y limpia `group`, igual que hoy (`Index.tsx:920-928`). Un estado en cero no se ofrece,
salvo que sea el vigente — la regla ya está en `Index.tsx:836-838` y se conserva.

**Abajo — el `CoverageStateBadge` de cada fila**, con su columna de fecha al lado (RT-02). Sin
cambios de contenido; sólo pasa a rendirse por `StatusBadge` para dejar de tener silueta de tarjeta.

Así los diez estados nunca ocupan la pantalla: ocupan un popover que se abre cuando alguien quiere
llegar a ese nivel. Y el resumen de arriba no puede mentir, porque no tiene dónde: sus tres cifras
son las tres familias, con los dos denominadores a la vista.

#### La tabla

**Cinco columnas, no siete.**

| # | Columna | Contenido | Ordenable | Alineación |
|---|---|---|---|---|
| 1 | **URL** | `<Link href={tableUrl(urls,{url:loc})}>` con la dirección entera, `translate="no"`, `wrap-anywhere` | sí (`url`, orden por omisión, asc) | izquierda |
| 2 | **Estado** | `CoverageStateBadge` vía `StatusBadge`, `whitespace-nowrap` | sí (`state`) | izquierda |
| 3 | **Dato obtenido** | `CoverageFetchedAt` → `DataTimestamp`, «—» sólo si `UNKNOWN` | sí (`fetched`) | izquierda |
| 4 | **Motivo declarado por Google** | `raw_coverage_state` crudo, `translate="no"` | **no** (sale por subconsulta) | izquierda |
| 5 | **En el sitemap** | «Sí» / `Badge` «Ya no aparece» | sí (`sitemap`) | izquierda |

Las cuatro ordenables son exactamente las cuatro que el servidor declara en `COVERAGE_TABLE.sortable`
(`services.py:602-608`); ninguna se ofrece ordenable sin índice detrás. El orden por omisión sigue
siendo `url` y **no** `state`, por el motivo que ya está escrito en el servidor: ordenar por estado
amontona las «sin consultar» en la última página, invisibles.

**La fecha va en columna propia y nunca en un tooltip** (RT-02). Es la columna 3 y no se toca; lo que
se va del ancho son las dos columnas vacías, no ésta.

**Densidad**: `py-3` con `align-top`, la estándar del contrato. `py-2` no aplica: con `wrap-anywhere`
una dirección larga ocupa dos renglones y la condición del contrato es que **todas** las celdas sean
de una línea. Con el enlace «Ver el historial» absorbido por la dirección y el badge sin partirse,
una fila de una línea mide ~48 px contra los ~85 de hoy: **44 % más corta con la misma información**.
50 filas por página (RT-09), servidor, total visible.

**Detalle de una URL: `Sheet`.** Recorriendo el árbol del contrato en orden y parando en el primer sí:

1. ¿Destructivo o irreversible? **No** — es lectura pura.
2. ¿Identidad y URL propias, alguien llega desde afuera? **No.** El panel tiene dirección (`?url=`)
   pero es un parámetro de esta página, no un recurso: el servidor no tiene ninguna vista de URL
   suelta (`apps/coverage/views.py` expone `index`, `export`, `download`, `inspect` y nada más), y el
   historial no significa nada fuera del dominio que lo contiene. Ninguna notificación ni ningún
   enlace externo apunta ahí.
3. ¿Es un formulario? **No.**
4. **¿Es el detalle de una fila y hay que volver a la lista sin perderla? Sí → `Sheet`.** Se para acá.

Que además sea lo que el `ux-checklist` manda para V7 (FR-060, T080: «se abre desde la fila, sin salir
de la vista ni perder el filtro») confirma la lectura, pero la decisión sale del árbol. Y el contrato
ya lo señala como «hoy el único uso correcto» de `sheet` en el producto: **no se cambia el patrón, se
cambia el disparador**. Hoy se abre desde un enlace secundario colgado bajo la dirección; pasa a
abrirse desde la dirección misma, en la primera celda, que es lo que hace funcionar Cmd+clic, el clic
del medio y el teclado. La fila entera **sigue sin ser clickeable**.

Abajo de `md` el mismo componente se presenta como `Drawer` — es presentación, no otra decisión.

Contenido del `Sheet`, en orden: título «Historial de <url>» · estado vigente con su `StatusBadge` y
su fecha · **`DescriptionList` de cuatro pares** que llegan desde las columnas que se fueron
(canónica de Google, último rastreo, en el sitemap, motivo crudo vigente) · la `<ol>` de la línea de
tiempo tal como está · una sola acción, el enlace al lote. El esqueleto (`TimelineSkeleton`) y el
adelanto optimista de apertura y cierre (`Index.tsx:299-324`) se conservan enteros.

#### Los filtros

Cuatro ejes reales, no tres botoneras: `q` (texto), `group` (4 valores), `state` (10 valores),
`sitemap` (3 valores). Y `group` y `state` **son el mismo eje**: el propio código los hace pisarse
(`clears`, `Index.tsx:800` y `:833`) porque son dos formas de preguntar lo mismo. Dibujarlos como dos
botoneras separadas, una arriba y otra abajo de una nota al pie, es lo que produce tres renglones y
catorce botones.

**Una sola línea de ~36 px, cuatro ranuras** (`Section` con `h2` sr-only «Filtros de la lista», tal
como el contrato manda para esta vista):

```
[🔍 Buscar en la dirección… ]  [ Todas 120 | Con dato 92 | No indexadas 30 | Sin consultar 28 ]  [ Estado ▾ ]  [ Más filtros ▾ ② ]
        InputGroup ~260 px                    ToggleGroup «Recorte» ~460 px                       Popover ~130      Popover ~150
```

- **Visible siempre**: la búsqueda y el **Recorte**. El recorte es la pregunta que se hace todos los
  días y son cuatro opciones —dentro del ≤5 que el contrato asigna a `ToggleGroup`—; y las cuatro son
  las tres familias de RT-03 más el total. Es el único control que la vista de hoy ya hace bien: se
  conserva la mecánica de enlaces reales con `preserveScroll preserveState`, cambia el envoltorio.
- **Plegado**: **Estado** (`Popover`, 10 opciones, contenido = `CoverageStateList`) y **Más filtros**
  (`Popover` con «En el sitemap» como `ToggleGroup` de 3 y la explicación de las URLs retiradas al
  lado del control que las filtra).
- **Nada de rótulos en renglón propio.** Hoy «Buscar en la dirección», «Recorte», «En el sitemap» y
  «Estado informado por Google» ocupan cuatro renglones de T6 sólo para nombrar grupos. El nombre del
  grupo pasa a `aria-label` del `ToggleGroup` y a la etiqueta del disparador del popover, que es
  donde además sirve para mostrar el valor puesto.

**Cómo se ve «hay 3 filtros puestos»** — dos mecanismos, cada uno con su trabajo:

1. **Cada disparador dice lo suyo.** «Estado» pasa a «Estado: Rastreada, sin indexar»; «Más filtros»
   lleva un `Badge` con la cantidad que retiene. Está en el control que hay que tocar para cambiarlo.
2. **Cuando hay 2 o más filtros, una línea T6 de auditoría** debajo de la barra: «Filtrando por:
   recorte No indexadas · estado Rastreada, sin indexar · con “/producto” en la dirección —
   **Limpiar todo**». Ese texto **ya existe**: es lo que devuelve `filterDescription()`
   (`Index.tsx:700-715`) y hoy sólo lo escucha un lector de pantalla dentro del `<caption>`. Hacerlo
   visible cuesta cero, elimina la divergencia entre lo que se le dice a un usuario vidente y a uno
   que navega con lector, y es lo que resuelve la llegada desde un aviso.

**Cómo se limpian**: «Limpiar todo» al final de esa línea, con `unfilteredUrl()` tal cual está
(`Index.tsx:718-722`). Y cada filtro individual se quita volviendo a elegir su opción «Todas» en su
propio control — sin inventar chips con ✕, que serían un tercer lugar donde vive el mismo estado.

**Cómo viaja en la URL**: sin cambios. `tableUrl()` es la única fuente de direcciones, cada control es
un `<Link>` real, cambiar cualquier filtro vuelve a `page: null` y cierra el panel con `url: null`
(`Index.tsx:920-928`), y la paginación es del servidor (RT-09). **Lo que sí se decide por escrito**:
el `Popover` abierto **no** va a la URL. El contrato pide que el estado de revelación sea enlazable, y
un popover no revela contenido propio — todo lo que muestra ya está en la dirección como el filtro que
pone, y reabrirlo es un clic. El `Sheet` sí va, y ya va (`?url=`). Dejarlo escrito evita que alguien
agregue `?popover=state` «por consistencia».

**La llegada con filtro puesto**, que hoy es el peor caso: con `?state=FETCH_ERROR`, en el nuevo
esquema el disparador dice «Estado: Error al descargarla» en y≈324, la línea de conteo dice «12 URLs ·
página 1 de 1» en y≈372 y la primera fila está en y≈440. Todo sobre el fold, sin scroll. Las tres
`MetricCard` siguen midiendo el dominio entero y no el recorte, a propósito: un denominador que se
mueve con el filtro es la forma más rápida de perder la referencia.

#### Las acciones sobre selección

**No hay selección de filas, y no se inventa una.** Cuatro razones, en orden de peso:

1. **El servidor no la puede recibir.** `coverage.inspect` no acepta ninguna lista de URLs: llama a
   `queue_inspection(domain, limit=domain.manual_reserve, origin=MANUAL)` (`views.py:160-179`).
   `coverage.export` recibe **filtros**, no identificadores: `applied_filters(COVERAGE_TABLE,
   request.POST)` (`views.py:100-123`). Proponer casillas es proponer dos endpoints nuevos, y el
   brief prohíbe agregar capacidades: sólo se decide dónde vive lo que hay.
2. **Mentiría sobre qué se va a consultar.** La cola la elige `pending_urls()`: primero las nunca
   consultadas, después por `priority_score`, después por antigüedad (`services.py:121-150`). Alguien
   que tilda 47 filas y aprieta «Inspeccionar» esperaría **esas** 47; el sistema consultaría otras 47.
   Es peor que no ofrecer la acción.
3. **El contrato ya lo cerró**: `checkbox` está instalada y deliberadamente sin usar.
4. **Y sobre todo: el recorte ya es la selección, y es mejor.** Vive en la URL, se puede copiar y
   guardar, sobrevive a recargar y a paginar, es lo que la exportación se lleva de verdad
   (`services.py:671-681`) y está dicho en palabras en el `<caption>` y —con este rediseño— en
   pantalla. Una selección de casillas no sobrevive ni al cambio de página.

**Dónde aparecen las acciones sobre el recorte**: en el slot `actions` de la `Section` que contiene la
tabla, en la misma línea que el conteo. Un solo control, con la cifra adentro: «Exportar 30 URLs», que
se recalcula con `urls.count`. Los dos caminos del umbral se resuelven así, sin ofrecerle al usuario
una decisión que no es suya:

- **Por debajo de `EXPORT_THRESHOLD` (5.000)**: es un `<a href>` con los filtros en la querystring.
  Baja en el acto, sin diálogo. Un modal para confirmar un CSV de 40 filas es fricción pura.
- **Por encima**: el botón abre un `Dialog` que dice, **antes** de confirmar, lo que FR-058 exige:
  «Son 128.400 URLs, más de las 5.000 que entran en una descarga directa. El archivo se prepara en
  segundo plano y te avisamos acá cuando esté listo». Primaria: «Preparar el archivo con 128.400
  URLs». Es `Dialog` y no `AlertDialog`: no se destruye nada.
- El rastro del pedido queda en `ExportStatusCard` al pie de la tabla, con sus cuatro estados
  (preparando con `BatchProgress` · lista con tamaño y vencimiento · falló · venció). RT-11 se cumple
  igual que hoy; lo que cambia es que ocupa 0 px cuando no hay ninguna exportación.

#### El presupuesto de cuota

Hoy V7 **no rinde `QuotaMeter` en absoluto** y «Consultar ahora» dispara `inspect.post` sin
confirmación y sin decir qué va a gastar (`Index.tsx:379-389` y `:575-587`). La misma acción, llamada
desde V6, pasa por una confirmación que nombra los dos bolsillos del cupo con sus cifras
(`Domains/Show.tsx:459-531`). **Dos puertas al mismo endpoint que dicen cosas distintas.**

- **`QuotaMeter` no vive en la página de cobertura.** Es un medidor de dos bolsillos con fecha y dos
  barras, ~200 px, y contesta una pregunta que nadie se hace mientras lee cobertura. Su casa sigue
  siendo V6 («Cupo de inspección») y V10.
- **Vive adentro de la confirmación de la acción que lo gasta.** «Consultar ahora» abre un `Dialog`
  —no un `AlertDialog`: no se destruye nada, y el contrato pone «Encolar inspección manual» bajo
  `Dialog`— cuyo cuerpo es, en este orden: la consecuencia en una línea nombrando los dos bolsillos
  por separado, el `QuotaMeter` completo, y la primaria. Ahí sus 200 px se ganan el lugar: es el único
  momento del día en que el cupo decide algo.
- **El encabezado sigue midiendo una línea.** `actions` = `ButtonGroup [Sitemaps] [Consultar ahora]`,
  los dos `size="default"`, sin badge, sin fecha, sin explicación. La cifra del cupo no toca el
  encabezado en ningún momento — que es exactamente lo que ya se rompió una vez.
- **Después de confirmar**, la `Card` «Consultando a Google» aparece bajo las métricas con
  `BatchProgress` y el enlace al lote: ése es el acuse (RT-13), y el botón queda deshabilitado con el
  motivo a la vista, como hoy.
- **Una corrección de redacción que el código impone: «hasta N», nunca «N».**
  `queue_inspection()` deja el lote en `QUEUED` con `total_items` en cero y **la tarea elige las URLs
  cuando corre**, con el cupo que quede en ese momento (`services.py:153-182`, y el docstring lo dice
  con todas las letras). Además hoy V6 calcula `min(pending, manualRemaining)` mientras el servidor
  manda `limit=domain.manual_reserve`, que son dos números que pueden no coincidir: la confirmación
  puede prometer más de lo que el lote va a hacer. La única forma honesta es un techo: «¿Consultar
  **hasta** 200 URLs de ejemplo.com?» / «Te quedan 153 reservadas para uso manual hoy, más 47 que el
  ciclo automático todavía no gastó» / «Consultar hasta 200 URLs». Y eso resuelve el «esto va a
  consumir 47 de tus 200 consultas de hoy» del encargo sin convertirlo en una promesa que el sistema
  no puede sostener (RT-05).

#### Esquema de la pantalla propuesta, de arriba abajo

Viewport 1440×900, columna de contenido ~1.080 px. Entre paréntesis, el alto aproximado.

```
 y=0    ┃ ENCABEZADO (armazón, intocado)                                       (48)
        ┃ h1 «Cobertura de ejemplo.com»          [Sitemaps] [Consultar ahora]
        ┠─────────────────────────────────────────────────────────────────────
 y=48   ┃ (gap-6 de <main>)                                                    (24)
 y=72   ┃ description — «Qué URLs tienen dato de Google, cuál es y de cuándo    (20)
        ┃ lo obtuvimos.»  · T5 muted, max-w-prose, una línea
 y=92   ┃ (gap-6)                                                              (24)
 y=116  ┃ PageIntro — una línea, separadores ·                                 (20)
        ┃ «92 de 120 URLs tienen dato de Google» · «Los datos van del 14 ago
        ┃ al 17 ago» · «Del lote 407c4293» ⟨HoverCard⟩
 y=136  ┃ (gap-6)                                                              (24)
 y=160  ┃ ┌──────────────┐ ┌──────────────┐ ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐                  (140)
        ┃ │ Indexadas    │ │ Sin indexar  │ ╎🕐Sin consultar╎  3 MetricCard
        ┃ │  62          │ │  30          │ ╎  28          ╎  size="sm",
        ┃ │  de 92 c/dato│ │  de 92 c/dato│ ╎  de 120 URLs ╎  igual ancho
        ┃ │ 67,4 % de las│ │ Ocho motivos.│ ╎ No significa ╎  la 3ª con
        ┃ │ que tienen…  │ │ El desglose… │ ╎ que no estén…╎  tone="unknown"
        ┃ │ · 17 ago     │ │ · 17 ago     │ ╎ (sin fecha)  ╎
        ┃ └──────────────┘ └──────────────┘ └╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘
 y=300  ┃ (gap-6)                                                              (24)
 y=324  ┃ FILTROS · <h2 sr-only>                                               (36)
        ┃ [🔍 Buscar…] [Todas 120|Con dato 92|No indexadas 30|Sin consultar 28]
        ┃                                     [Estado ▾] [Más filtros ▾]
 y=360  ┃ (gap-3)                                                              (12)
 y=372  ┃ «120 URLs · página 1 de 3»                    [Exportar 120 URLs]    (28)
 y=400  ┃ ┌─ encabezado fijo (T6, sticky top-0) ──────────────────────────┐    (40)
        ┃ │ URL ⇅ │ Estado ⇅ │ Dato obtenido ⇅ │ Motivo │ En sitemap ⇅   │
 y=440  ┃ ├───────────────────────────────────────────────────────────────┤
        ┃ │ https://ejemplo.com/nota/0001 │ ✓ Indexada │ anteayer │ — │ Sí│ (48)
 y=488  ┃ │ https://ejemplo.com/nota/0002 │ ✓ Indexada │ anteayer │ — │ Sí│ (48)
 y=536  ┃ │ …                                                             │
        ┃ │  filas 3 a 10 …                                    (8 × 48)   │
 y=872  ┃ │ https://ejemplo.com/nota/0010 │ ✓ Indexada │ anteayer │ — │ Sí│ (48)
 ═══════╋═══ FOLD 900 ═══════════════════════════════════════════════════════
        ┃ │  filas 11 a 50, dentro del mismo contenedor max-h-[70vh] con el
        ┃ │  encabezado fijo: se desplazan sin mover la página             │
        ┃ └───────────────────────────────────────────────────────────────┘
        ┃ <nav> ‹ Anterior · Página 1 de 3 · Siguiente ›                       (40)
        ┃ (gap-6)                                                              (24)
        ┃ nota de vocabulario RT-05, un solo párrafo, max-w-prose              (40)
        ┃ TimezoneFootnote — «Todas las horas en America/Argentina/…»          (20)

CONDICIONALES (0 px cuando no aplican):
  · Alert «Estos datos están congelados» ......... arriba de todo, si !is_operational
  · ServerErrorNotice ............................ arriba de la Section que falló (RT-08)
  · Card «Consultando a Google» + BatchProgress ... bajo las métricas, sólo con lote en curso (~110)
  · Card «Exportación» ........................... al pie de la tabla, sólo si hay una (~96)
  · línea T6 «Filtrando por: … — Limpiar todo» .... bajo la barra, con 2+ filtros (~24)
  · Sheet «Historial de <url>» ................... sobre todo, desde ?url= (Drawer abajo de md)
```

**Qué cambia el fold**: la primera fila de datos pasa de y≈1398 a y≈440, **958 px más arriba**. Al
llegar se ven las tres cifras que contestan la pregunta de la vista, el recorte que se está mirando
—incluso si llegó puesto desde un aviso—, y **diez URLs** con su estado y su fecha. Hoy se ven cero.

### Componentes compartidos que necesita

Del contrato ya salen `PageIntro`, `Section`, `Card`, `MetricCard`, `DataTable`, `StatusBadge`,
`EmptyState`, `DescriptionList`, `Item`, `Spinner`. Lo que **falta declarar** por esta vista:

- **`FilterBar`** — la región de filtros de una lista, en una línea. Props
  `{ children: ReactNode; appliedSummary?: string; clearHref?: string }`. Rinde la `Section` con `h2`
  sr-only, la fila de controles y —con `appliedSummary`— la línea T6 de auditoría con «Limpiar todo».
  Reemplaza `Filters` (`Index.tsx:737-856`) y la región equivalente de V8.
- **`FilterToggleGroup`** — un eje de ≤5 valores como `ToggleGroup` de enlaces reales. Props
  `{ label: string; param: string; table: TablePage<any>; allLabel: string; options: {value,label,count?}[]; clears?: string }`.
  Reemplaza `FilterGroup` + `FilterOption` (`Index.tsx:858-942`), que hoy dibujan fichas con
  `Button asChild` y son la causa material de 14 de los 19 botones.
- **`FilterPopover`** — un eje plegado. Props `{ label: string; value?: string; count?: number; children: ReactNode }`.
  El disparador muestra su valor puesto («Estado: Indexada») o su cantidad en un `Badge`.
- **`CoverageStateList`** — los diez estados en las tres familias de RT-03, con `StatusBadge`, cuenta
  y porcentaje sobre `with_data`; `UNKNOWN` bajo un `Separator`, sin porcentaje. Props
  `{ states: {value,count}[]; withData: number; current?: string; hrefFor: (value: string) => string }`.
  **Reemplaza dos cosas a la vez**: la tabla de reparto de `CoverageSummary.tsx:199-230` y la botonera
  de estados de `Index.tsx:829-843`. Es la pieza que resuelve «diez estados sin diez botones».
- **`QuotaCostDialog`** — la confirmación de una acción que gasta cupo diario. Props
  `{ trigger: ReactNode; title: string; ceiling: number; quota: QuotaProps; consequence: string; confirmLabel: string; busy: boolean; onConfirm: () => void }`.
  Cuerpo = `QuotaMeter`. Lo usan V7 («Consultar ahora») y V6 («Inspeccionar ahora») para que las dos
  puertas a `coverage.inspect` digan lo mismo. Reemplaza el `ConfirmDestructive` mal usado de
  `Domains/Show.tsx:513-530` y el `inspect.post` sin confirmación de `Index.tsx:379-389`.
- **`ExportAction`** — el botón con sus dos caminos. Props
  `{ path: string; filters: Record<string,string>; count: number; threshold: number; queueRoute: string }`.
  Bajo el umbral, `<a href>`; sobre el umbral, `Dialog` que enuncia el comportamiento antes de
  confirmar (FR-058). Reemplaza `ExportPanel` (`Index.tsx:953-1051`) menos su estado.
- **`ExportStatusCard`** — el rastro persistente del último pedido grande, cuatro estados, `Card size="sm"`,
  condicional. Props `{ export: CoverageExport }`. Reemplaza `ExportStatus` (`Index.tsx:1067-1130`).
- **`BatchProvenance`** — «Del lote <id corto>» como `<Link>` con `HoverCard` de 4 datos (estado,
  origen, procesadas de total, fecha de cierre). Props `{ batch: Batch }`. Va como ítem de
  `PageIntro`. Reemplaza la mitad derecha de `InspectActivity` (`Index.tsx:653-684`) y sirve igual en
  V6, V9 y V10.
- **`RunningBatchCard`** — `Card size="sm"` con `BatchProgress` y el enlace al lote, sólo mientras el
  lote no es terminal. Props `{ batch: Batch }`. Reemplaza la mitad izquierda de `InspectActivity`.
  Sirve en V6 y V8.
- **`UrlDetailSheet`** — renombre de `HistoryPanel` con el `DescriptionList` de cuatro pares que llega
  desde las columnas retiradas. Misma firma. `Sheet` en `md+`, `Drawer` abajo.

Primitivas a instalar que esta vista necesita y hoy no existen: `popover`, `dialog`, `hover-card`,
`button-group`, `input-group`, `empty`, `item`, `separator` (instalada, sin usar), `tooltip`
(instalada, sin usar), `toggle-group` (instalada, sin usar).

### Qué no tocar

- **El armazón.** `AppLayout` y `SiteHeader` quedan como están, y `actions` sigue siendo de una línea:
  `ButtonGroup [Sitemaps] [Consultar ahora]`, sin badges, sin fechas, sin el cupo.
- **El mapa `STATES` de `CoverageStateBadge`** (`CoverageStateBadge.tsx:29-100`): las diez etiquetas,
  las diez explicaciones, los diez iconos distintos, las tres familias y el comentario que explica por
  qué `OTHER_NOT_INDEXED` tiene icono propio. Ahí vive el conocimiento del dominio. Sólo cambia el
  `<span>` por `StatusBadge`, y el `title=` por `Tooltip`.
- **`coverage_summary()` y `STATE_GROUPS` del servidor** (`services.py:375-410` y `:582-588`):
  `not_indexed` excluye `UNKNOWN` por construcción, `indexed_percentage` es nulo y no cero, y
  `by_state` viaja sin `UNKNOWN`. Toda la propuesta se apoya en eso.
- **`COVERAGE_TABLE`** y su `default_sort='url'`, con el razonamiento de por qué no es `state`.
- **La querystring como único almacén de estado**: `tableUrl()`, cada control como `<Link>` real,
  `preserveScroll preserveState`, `page: null` y `url: null` al cambiar de filtro.
- **El adelanto optimista del panel** (`desiredUrl` / `lastUrlInAddress`, `Index.tsx:299-324`) y el
  descarte a `undefined` cuando aterriza la navegación. Es sutil y está bien.
- **`useBatchPolling` como único mecanismo de actualización** (RT-13), con `history` deliberadamente
  fuera de `only:` para que una recarga no cierre el panel abierto.
- **`filterDescription()`**: no se reescribe, se muestra.
- **La exportación del servidor**: `EXPORT_THRESHOLD`, `queue_export`, y las **ocho** columnas de
  `EXPORT_HEADER`. Que el CSV lleve dos columnas más que la pantalla no viola la regla escrita en
  `services.py:810-813`: esa regla prohíbe lo contrario —que se vea algo que no se exporta—.
- **`CoverageFetchedAt`** devolviendo «—» sólo para `UNKNOWN`, y «Sin fecha de obtención» en
  tratamiento de error cuando falta la fecha con estado presente.
- **El `Alert` con `Snowflake`** para el dominio no operativo, con su fecha y su acción.
- **La sustitución de `InspectAction`** (RT-18): con la cuenta o el dominio sin poder operar, la acción
  se **reemplaza** por la que destraba el problema, no se deshabilita.
- **`EXPORT_THRESHOLD` y los dos caminos** siguen siendo decisión del servidor: la pantalla los
  describe, no los elige.

### Reglas en juego

- **RT-01** — Toda fecha por `DataTimestamp`, zona de la cuenta, y `TimezoneFootnote` al pie. Se
  conservan las tres fechas de la vista, que son distintas: la de obtención del dato, la del último
  rastreo de Google y la de cierre del lote.
- **RT-02** — «Dato obtenido» es columna propia, la 3, y sobrevive el rediseño. La fecha **no** se va
  al `Sheet` ni al `title`; lo que se va son la canónica y el último rastreo, que no son la fecha de
  obtención. Y el `help` de los badges pasa de `title=` a `Tooltip`, con la palabra siempre legible
  sin el tooltip.
- **RT-03** — Tres `MetricCard` = tres familias. Los dos porcentajes llevan «de 92 con dato» al lado.
  `UNKNOWN` tiene su tarjeta con `tone="unknown"`, su grupo aparte en el popover **sin porcentaje**, y
  no entra en ninguna barra. La barra apilada a mano de `CoverageSummary` se borra, así que no queda
  ninguna barra en la que meterlo por descuido.
- **RT-04** — Icono + palabra, en ese orden, en las diez filas del popover y en las 50 de la tabla. La
  prueba sigue siendo la captura en escala de grises.
- **RT-05** — La estimación del ciclo mantiene su redacción («recorrer», «podemos volver a
  consultar»), el disclaimer se escribe **una** vez al pie en vez de dos, y la confirmación del gasto
  de cupo dice «hasta N» y no «N».
- **RT-07** — El botón «Consultar ahora» no queda deshabilitado con un globo: se reemplaza (RT-18) o
  se apaga con el motivo escrito en la `Card` del lote en curso.
- **RT-08** — `ServerErrorNotice` arriba de la `Section` que falló, con el mapa cerrado de
  `ERROR_ACTIONS` (`Index.tsx:162-172`) intacto.
- **RT-09** — Paginación del servidor, 50 filas, total visible, encabezado fijo, `aria-sort`,
  `wrap-anywhere` en las direcciones, cero scroll horizontal de página. Todo eso ya lo hace
  `DataTable`; lo único que cambia es que la tabla arranca arriba del fold, que es lo que hace que
  `max-h-[70vh]` signifique algo.
- **RT-11** — El pedido de exportación queda **escrito** en `ExportStatusCard`, no en un toast. La
  región viva del conteo sigue anunciando el total al cambiar de filtro.
- **RT-13** — El lote en curso nunca se presenta como terminado; el avance sale de las cifras y el
  sondeo sigue siendo `useBatchPolling`.
- **RT-15** — El `Sheet` atrapa el foco, cierra con `Escape` y lo devuelve al enlace de la primera
  celda que lo abrió. El `Popover` de filtros devuelve el foco a su disparador.
- **RT-18** — El estado de la cuenta manda: `canOperate` decide si «Consultar ahora» existe o se
  reemplaza.
- **Contrato §6**: 6 (ninguna página define su borde de tarjeta — hoy V7 define cinco), 7 (ninguna
  `Card` con `p-*` propio), 8 (ninguna `Card` dentro de otra), 12 (dos piezas del mismo tipo, mismo
  ancho — hoy `2fr/1fr`), 13 (la lista pasa por `DataTable`), 16 (fecha en columna propia), 17
  (porcentaje con denominador), 19 (la fila no es clickeable; el detalle sale de un `<Link>`), 21
  (`actions` en una línea), 24 (no mezclar `size="sm"` con `size="default"` en la misma fila — hoy
  «Buscar» es `sm` al lado de un `Input` de altura por omisión).
- **Movimiento (§3.12)**: filtrar, ordenar y paginar **no se animan**; el único aviso es el
  intercambio por el esqueleto, que ya existe. El `Popover` entra en 150 ms escalando desde su
  disparador (`--radix-popover-content-transform-origin`), el `Sheet` y el `Dialog` en 200 ms, todo
  con `motion-reduce:`.

---

## Lo que aprendí que sirve para las otras vistas

1. **Un resumen de sólo lectura que repite las cifras de un control es un control disfrazado.** Acá el
   reparto por estado y la botonera de estados son el mismo dato a 400 px de distancia. Vale la pena
   buscar el mismo par en las otras cinco vistas —conteo por estado + filtro por estado— y fusionarlos
   en la misma pieza. Es la reducción más grande de esta vista y no quita ninguna información.
2. **Si una notificación puede aterrizar en una vista con querystring, la vista tiene que decir en
   palabras y sobre el fold qué recorte está mirando.** `apps/notifications/services.py` arma enlaces
   con filtro para cobertura y para dominios; hoy V7 sólo lo dice en un `caption` `sr-only` y a 1.000
   px de scroll. Propongo subirlo al contrato §6 como regla verificable: *toda vista alcanzable con
   filtro desde un aviso muestra el filtro vigente antes del primer scroll*.
3. **Si ya existe un texto escrito para el `caption` o el `aria-label` que describe el estado de la
   vista, mostrarlo es gratis.** `filterDescription()` estaba escrito, probado y era invisible.
   Cualquier divergencia entre lo que oye un lector de pantalla y lo que ve el resto es una
   divergencia que se va a notar en el peor momento.
4. **Ninguna cifra que dependa del cupo que quede al ejecutar puede escribirse sin «hasta».** El lote
   elige las URLs cuando corre, no cuando se aprieta el botón. Vale para V6, V9 y la API, y hoy V6 ya
   promete un número que el servidor puede no cumplir. Es material de contrato, al lado de RT-05.
5. **Una columna vacía en todas las filas visibles no es una tabla ancha: es un `Sheet` mal
   repartido.** Criterio verificable que propongo: *si una columna muestra el marcador de vacío en más
   del 80 % de las filas de la primera página, se va al detalle de la fila*. Acá liberó 320 px que la
   columna de URL necesitaba para no partir cada dirección en dos renglones.
6. **`max-h-[70vh]` mide contra el viewport, así que su valor depende de dónde arranque la tabla.** Si
   la lista empieza en y=1400, su ventana de 70vh se gasta entera fuera de pantalla y la acotación no
   sirve para nada. Consecuencia de contrato: *en toda vista que sea una lista, la tabla arranca sobre
   el fold*. Y ojo con las mediciones: `70vh` en una captura de página completa infla el alto medido
   del documento; el número honesto es «cuántas filas se ven al llegar».
7. **Un badge dibujado a mano con `rounded-md border` cuenta como tarjeta en cualquier auditoría
   estructural.** Es la explicación material de las «25 tarjetas» de esta vista y confirma la decisión
   §3.7 del contrato: los tres badges de estado conservan sus mapas y pasan a rendirse por
   `StatusBadge`. Sin eso, el conteo de tarjetas de cualquier vista con tabla va a seguir mintiendo.
8. **Dos puertas al mismo endpoint tienen que decir lo mismo.** V6 y V7 llaman a `coverage.inspect`:
   una confirma nombrando los dos bolsillos del cupo y la otra dispara en silencio. Regla: *un
   endpoint que gasta un recurso, un solo componente de confirmación compartido*. Es también la razón
   por la que `QuotaMeter` no se reparte por las pantallas: vive donde se gasta.
9. **`ConfirmDestructive` no es el envase de toda confirmación.** V6 lo usa para encolar una
   inspección, que no destruye nada. Cuando el árbol del contrato dice `Dialog`, usar `AlertDialog`
   entrena a la gente a ignorar el diálogo que sí importa.
10. **Un párrafo escrito dos veces en la misma pantalla es una señal de que el bloque que lo repite no
    era un bloque.** El disclaimer de RT-05 aparece en `CoverageSummary` y otra vez en `Index`: la
    advertencia es del producto y va una sola vez, al pie. Vale la pena grepear el resto de las vistas
    por esa clase de duplicación antes de decidir dónde va cada cosa.
