# Grupo A · La puerta de entrada

Dos vistas: V0 Inicio y V13 Notificaciones. Son las dos pantallas a las que se llega sin haber
elegido llegar —la raíz y el contador del menú—, y las dos contestan la misma pregunta desde
lugares distintos: **¿qué me falta mirar?**

---

## V0 · Inicio — `/` — [`frontend/pages/Dashboard.tsx`](frontend/pages/Dashboard.tsx)

**Veredicto**: reestructurar — **severidad** 5/5
**El problema en una línea**: la pantalla contesta cuatro preguntas con el mismo peso visual y no
pide ninguna acción, así que el ojo no tiene por dónde empezar y las manos no tienen qué hacer.

### Qué pasa hoy

- **El único botón que se ve al entrar es el que no corresponde.** En el fold hay exactamente un
  control primario: «Agregar dominio», en el `actions` del encabezado. El bloque de atención
  —`AttentionSection`, líneas 215-311— **no tiene ningún botón**: sus ítems son enlaces subrayados
  dentro de un párrafo muted. O sea que a alguien que tiene un dominio en «Esperando acceso» la
  pantalla le ofrece agregar un segundo dominio y no autorizar el primero.

- **Los mismos cinco números están dibujados dos veces, a 200 px de distancia y con dos
  tipografías.** `coverageHeadline()` (línea 458) escribe «92 de 120 URLs tienen dato de Google, en
  1 dominio. Las otras 28 todavía no se consultaron» en un `h2` de 20 px, y 200 px más abajo
  `CoverageCard` (`section-cards.tsx:175`) escribe `92 de 120` a 24–30 px con el badge «28 sin
  consultar» y el pie «En 1 dominio». Los cinco valores —92, 120, 28, «1 dominio», la fecha— están
  los dos veces. **Hay dos «primeros» en la pantalla, y por eso no hay ninguno.**

- **Los encabezados no forman una jerarquía: forman una pila.** Tres `h2` en dos tamaños
  —`text-xl font-medium` en las líneas 238, 371 y 413 (20 px, prohibido por el contrato §1) y
  `text-sm font-medium` en la 492 (14 px)— y, además, **cinco títulos de tarjeta que no son
  encabezados**: `CardTitle` de `ui/card.tsx:36` rinde un `<div>`, así que el gráfico y las cuatro
  métricas no tienen etiqueta semántica ninguna. El `@[250px]/card:text-3xl` de las cuatro tarjetas
  (`section-cards.tsx:120, 182, 245, 294`) es de dónde sale el 30 px medido.

- **Seis cajas con borde dibujadas a mano, ninguna con la forma de la primitiva.** `Dashboard.tsx`
  importa de `@/components/ui/` **sólo `button`**; sus cajas son `rounded-lg border` en las líneas
  232, 233, 368 y 517 —y la 517 está adentro de un `map`, así que en pantalla son cinco—. La
  primitiva `Card` usa `rounded-xl` y `ring-1 ring-foreground/10`. Por eso las cajas de la mitad de
  arriba y las de la mitad de abajo son visiblemente distintas: son dos formas distintas de verdad.

- **El bloque «no hay nada que resolver» ocupa lo mismo que el bloque «hay algo roto».**
  `AllClearSection` (línea 366) es una caja con borde, un `h2` de 20 px con icono y un párrafo:
  ~92 px de fold para decir que no hay nada que hacer. La diferencia entre las dos situaciones queda
  reducida al tinte del borde —ámbar/rojo contra neutro—, que es RT-04 aplicado al revés a nivel de
  disposición.

- **Cinco filas de lotes cargan treinta elementos.** Cada `li` (líneas 514-571) trae seis cosas:
  enlace al lote, línea de origen + id corto + fecha, **un segundo enlace** «Ver todos los lotes de
  ejemplo.com», badge de estado, barra de avance y su conteo. Cinco filas × seis = los ~30 nodos de
  la última sección. Y arriba de ellas hay un `<p role="status">` que se rinde **vacío** cuando no
  hay trabajo en curso (líneas 503-505): un renglón fantasma con su `gap-3`.

- **Hay una tarjeta que casi todas las mañanas no dice nada.** La captura completa muestra
  «Consultas a Google hoy: —» con tres renglones de explicación («El cupo del 19 de agosto de 2026
  se abre cuando corre el primer lote del día»). Un cuarto de la fila de métricas ocupado por la
  explicación de por qué no hay cifra, todos los días hasta que corre el primer lote.

### En qué orden debería mirarse

Tres casos, y la pantalla tiene que verse distinta en cada uno. El servidor ya distingue los tres
sin agregar nada: `has_domains`, `coverage.total` y `hasSomethingToResolve(attention)`.

#### Caso A — cuenta sin ningún dominio (primer día)

Ojo con el contexto: `home` redirige al recorrido guiado mientras `should_offer()` sea cierto
(`apps/web/views.py:127`), así que llegar acá con cero dominios significa **«ya conectó Google o se
salteó el recorrido, y no cargó ninguna propiedad»**. No es alguien perdido: es alguien a un paso.

1. **La bajada de la página.** Hoy la rama de `!has_domains` (líneas 121-141) devuelve un
   `AppLayout` **sin `description`**: justo el día en que más falta hace, la pantalla pierde su
   subtítulo. Se pasa siempre.
2. **Una sola cosa: el `EmptyState`.** Título en T4 (que además es el único `h2` de la pantalla —hoy
   es un `<p className="font-medium">`, `EmptyState.tsx:23`, y la vista queda con **cero
   encabezados**), la explicación que ya está escrita —«Un dominio es una propiedad tuya en Search
   Console…»— y **un** botón primario: «Agregar tu primer dominio».
3. **Un enlace secundario, no un segundo botón.** «Retomar el recorrido guiado» pasa de
   `Button variant="outline"` a enlace de texto (contrato §3.8: una acción primaria y como mucho un
   enlace secundario). Hoy son dos botones del mismo peso y obligan a elegir entre dos caminos sin
   saber en qué se diferencian.
4. **Nada más.** Ni tarjetas en cero, ni gráfico vacío, ni pie de zona horaria: no hay ningún dato
   fechado que declarar. Esto ya lo hace bien el corte temprano y se conserva.

**La única acción que pide la pantalla: agregar el primer dominio.**

#### Caso A′ — hay dominios pero todavía no hay URLs (segundo día)

`has_domains === true` y `coverage.total === 0`. Hoy nadie lo trata aparte: se rinden cuatro
tarjetas con `—`, un gráfico con su caja punteada de «no hay actividad» y «Todavía no corrió ningún
lote». Seis bloques diciendo lo mismo, ~1.200 px.

1. La línea de estado (igual que el caso B).
2. **Un `EmptyState` en lugar de la fila de métricas y del gráfico**: «Todavía no descubrimos
   ninguna URL» + «Las URLs se descubren leyendo los sitemaps de cada dominio» —las dos frases ya
   existen, `coverageHeadline()` y `section-cards.tsx:221`— y un botón.
3. **La fila de métricas y el gráfico no se rinden.** No es quitar información: no hay ninguna.

**La única acción que pide la pantalla: sincronizar el sitemap del dominio.** Y acá está el dato que
convendría pedirle al servidor: con `domains.total === 1` el botón debería llevar a los sitemaps de
*ese* dominio, pero `dashboard.py::_domains()` sólo manda conteos y `attention.domains[]` sólo trae
los que necesitan acción. Con un `only_id: str | None` —el id cuando `total === 1`, nulo si no— el
botón deja de mandar al listado a elegir entre uno. Sin él, «Ir a Dominios», que es peor pero
honesto: el checklist prohíbe adivinar cuál dominio le importa a la persona.

#### Caso B — cuenta en régimen (todo funcionando, nada que hacer)

1. **`PageIntro`, una línea.** `StatusBadge tone="positive"` «Todo en orden» · «Comprobamos el
   acceso de tus 3 dominios hace 2 horas» · y, si el sondeo detecta trabajo,
   `StatusBadge tone="neutral"` «Trabajo en curso». ~24 px, no 92. La región viva de RT-13 vive acá,
   no al lado de la lista de lotes.
2. **La fila de cuatro `MetricCard`.** Es la respuesta a «¿cuánto sabemos hoy?» y es lo segundo
   que se ve, entero, arriba del fold. Orden: cobertura · cupo de hoy · último ciclo · dominios.
   La cobertura primero porque es la pregunta de la vista; los dominios último porque es inventario.
3. **El gráfico**, con su explicación **antes** y no al pie.
4. **Los últimos cinco lotes**, como filas y no como tarjetas.
5. **El pie de zona horaria.**

**La única acción que pide la pantalla: ninguna, y lo dice en la primera línea.** Ése es el punto.
«Agregar dominio» se queda en el encabezado como afordancia permanente, que no es lo mismo que un
pedido. Una herramienta que se mira todos los días tiene que poder decir «hoy no hay nada»; si
siempre empuja a hacer algo, se deja de creer en el aviso el día que hay algo de verdad.

#### Caso B′ — hay algo que resolver

La misma pila, con el paso 1 convertido en bloque en vez de línea:

1. **`Section` «Lo que necesita tu atención»**, sin borde y sin fondo de color, con un `Item` por
   cosa. El primer ítem lleva **el único botón primario de la pantalla**; los demás, su enlace.
   Excepción que ya está bien argumentada en el código y se conserva (líneas 256-261): si el
   problema es la credencial, el botón lo tiene el `AccountNotice` del armazón y el bloque no repite
   ninguno —con la credencial caída no hay nada más accionable—.
2. Todo lo demás, igual.

Y la diferencia entre B y B′ deja de ser un tinte: es **una línea contra un bloque con un botón**.
Eso sobrevive a la escala de grises y a la miniatura.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `AttentionSection`, caja `rounded-lg border p-4` con `h2` de 20 px (líneas 228-246) | `Section` sin borde ni fondo, con un `Item` por cosa | `Section` + `Item` + `StatusBadge` | La caja de color hace el trabajo que tienen que hacer el icono y la palabra (RT-04). Y el bloque necesita un botón: hoy no tiene ninguno |
| `AllClearSection` completa (líneas 366-389) | `PageIntro`, una línea con badge + fecha | `PageIntro` (§3.1) | «No hay nada que hacer» no necesita 92 px ni una caja. La asimetría con B′ es lo que hace que el tablero no se vea igual con todo bien y con algo roto |
| `h2` de `AccountCoverageLine` con la frase 92/120/28 (línea 413) | `MetricCard` «URLs con dato de Google»: `value="92 de 120"`, `denominator` = badge `28 sin consultar`, `note` = «62 indexadas de las 92 con dato · en 1 dominio», `fetchedAt` = `last_checked_at` | `MetricCard` (§3.4), `tone="unknown"` en el badge | El significado entero se conserva —con dato, total, sin consultar, en cuántos dominios, desde cuándo— y deja de estar escrito dos veces. Además T2 es una cifra, nunca una oración (§1) |
| Párrafo de `frozen_domains` (líneas 434-441) | Renglón del ítem del dominio no operativo, dentro del bloque de atención | `Item` → `ItemDescription` | Es la consecuencia de ese acceso perdido: se lee junto a su causa y no como un párrafo suelto tres bloques más abajo |
| Párrafo de `!canOperate` (líneas 448-453) | `Section.description` de la fila de métricas | `Section` (§3.2) | RT-18 pide decirlo **junto a las cifras**; el `AccountNotice` del armazón ya lo dice arriba con su botón, así que acá alcanza la advertencia y no un segundo botón al mismo lugar |
| Las cuatro `Card` de `section-cards.tsx` con `@[250px]/card:text-3xl` | Cuatro `MetricCard size="sm"` `h-full`, en `grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4` | `MetricCard` | Se borra la consulta de contenedor y la cifra queda clavada en T2 (§1). `size="sm"` es lo que corresponde a una fila de tres o más (§3.3) |
| Línea «1 espera una acción» de `DomainsCard` (líneas 147-153) | Se queda, pero como reparto del denominador: «1 esperando autorización» | `MetricCard.note` | División de trabajo: **la tarjeta dice cuántos, el bloque de atención dice cuáles y por qué**. Sin eso el mismo `1` aparece dos veces con dos redacciones |
| `ChartAreaInteractive` envuelto en `Card` con `CardTitle` que es un `<div>` | `Section` con su `h2` T3 y el gráfico adentro, sin `Card` | `Section` | Una `Section` con exactamente una `Card` adentro es un marco doble; y hoy el título del gráfico no es un encabezado, así que el bloque no tiene etiqueta semántica |
| `ToggleGroup` + `Select` de rango, hoy en `CardAction` | `Section.actions` | `ToggleGroup` (≤5 opciones, §5) | Una línea, un grupo de controles. El `Select` sigue siendo la presentación angosta del mismo control, no otro control |
| `<details>` «Ver los datos día por día» (`chart-area-interactive.tsx:275`) | `Collapsible` | `collapsible` ⛔ instalar (§4, punto 6) | Un solo bloque secundario que se pliega. El `<details>` a mano sólo sobrevive en `ServerErrorNotice` |
| Párrafo al pie del gráfico «Una consulta es una pregunta a Google…» (líneas 257-261) | `Section.description`, **arriba** del gráfico | `Section` | Es lo que impide leerlo mal. Leerlo después de haber mirado el dibujo llega tarde |
| Los cinco `li` `rounded-lg border p-3` de `RecentBatchesSection` | Cinco `Item` dentro de una `Section` | `item` ⛔ instalar (§3.6) | Cinco cajas con borde son cinco tarjetas que no son tarjetas. `Item` es exactamente [título con enlace] + [motivo muted] + [acciones] |
| «Ver todos los lotes de \<host\>» ×5 (líneas 549-557) | `DropdownMenu` de la fila, junto a «Ver el lote» | `dropdown-menu` ✅ (§4, punto 10) | Dos destinos por fila = menú. Saca cinco renglones de enlace de la pantalla sin sacar ningún destino |
| `<p role="status">` vacío (líneas 503-505) | La región viva sube a `PageIntro`, como badge «Trabajo en curso» | `PageIntro` + `StatusBadge tone="neutral"` | Que haya trabajo corriendo es estado de la cuenta y se contesta arriba. Hoy es un renglón fantasma con su `gap-3` al lado de una lista |
| `EmptyState` de `!has_domains` (líneas 121-141) | Misma pieza sobre `@shadcn/empty`, con la `description` de la página presente, título en T4/`h2`, un botón y un enlace | `empty` ⛔ instalar (§3.8) | Es la única pantalla del producto con **cero encabezados** y con dos botones del mismo peso |
| Fila de métricas + gráfico cuando `coverage.total === 0` | No se rinden; en su lugar un `EmptyState` | `EmptyState` | Cuatro tarjetas con `—` y un gráfico vacío son seis bloques diciendo «todavía nada» |
| `TimezoneFootnote` | Se queda al pie | — | RT-01 |

**Altura estimada del caso B después**: ~1.030 px contra los 1.782 de hoy; el fold pasa de 51 % a
~78 % y entra completa la fila de métricas más el título del gráfico. Es una estimación de
disposición, no una medición.

### Componentes compartidos que necesita

- **`PageIntro`** (contrato §3.1, ya definido) — la línea de estado de la página. Acá lleva el
  `StatusBadge` de «Todo en orden» / «Trabajo en curso» y la fecha del último control de acceso.
  Reemplaza `AllClearSection` entera y el `<p role="status">` vacío.
- **`Section`** (§3.2, ya definido) — las cuatro secciones de la vista. Reemplaza los cuatro
  `<section className="… rounded-lg border p-4">` y los tres `<h2 className="text-xl …">`.
- **`MetricCard`** (§3.4, ya definido) — las cuatro cifras. Absorbe `section-cards.tsx` **menos la
  consulta de contenedor** y absorbe la frase de honestidad como `value` + `denominator` + `note`.
- **`StatusBadge`** (§3.7, ya definido) — `BatchStateBadge` y `AccessStateBadge` conservan su nombre,
  su mapa y sus docstrings, y pasan a rendir a través de él. El `title=` de `BatchStateBadge:96` y el
  de las tarjetas (`section-cards.tsx:125, 133, 192, 250`) pasan a `Tooltip`.
- **`Item`** (§3.6, a instalar) — la fila de la lista de lotes y la del bloque de atención.
- **`EmptyState`** (§3.8, a instalar `empty`) — los dos vacíos de la vista.
- **`NoticeItem`** — **pieza nueva que pido agregar al contrato.** Es la composición fija de
  [`StatusBadge`] + [título con enlace, T4] + [motivo, T5 muted `max-w-prose`] + [`DataTimestamp`,
  T6] + [una acción primaria o un enlace] + [`DropdownMenu` opcional], sobre `Item`. Hoy está
  dibujada dos veces y distinta: `Dashboard.tsx:269-307` (ítem del bloque de atención) y
  `Notifications.tsx:314-414` (un aviso). Son el mismo objeto; una sale del estado y la otra de la
  tabla `Notification`, pero se leen igual y tienen que verse igual.
  Props: `{ badge?: ReactNode; title: ReactNode; description?: ReactNode; meta?: ReactNode;
  action?: ReactNode; menu?: ReactNode; tone?: 'default' | 'muted' }`.

### Qué no tocar

- **El armazón**: `AppLayout.tsx`, `site-header.tsx`, `app-sidebar.tsx`, `nav-*.tsx`. El ritmo de
  24 px sale de `<main className="… gap-6 …">` y ninguna sección lo redefine.
- **El razonamiento de las flechas de tendencia** (`section-cards.tsx:26-32`). Las piezas se mudan a
  `MetricCard`; el argumento —una variación porcentual sobre una muestra parcial es la restricción de
  honestidad dibujada con un dibujito— se conserva palabra por palabra.
- **Las dos series del gráfico, sin apilar, y el `strokeDasharray`** que las distingue en escala de
  grises. Y el contrato §5 ya cierra la discusión: no se agregan gráficos.
- **`useBatchPolling`** como único `setInterval` del producto, mirando la cuenta entera y no los
  cinco lotes listados.
- **`BatchProgress`**: la barra sale de las cifras y nunca del estado, y lo fallido va rayado. Es una
  de las dos únicas barras dibujadas a mano que el contrato autoriza.
- **El orden del DOM**: lo que necesita acción antes que cualquier número, no sólo arriba en la
  pantalla. El comentario de las líneas 160-165 explica por qué y sigue valiendo.
- **El contrato del servidor** (`apps/web/dashboard.py`): la propuesta reordena y reagrupa lo que ya
  llega. El único agregado que pido es `domains.only_id`, y es opcional.
- **`dayLabel()`** (`section-cards.tsx:346`): el día del cupo es una fecha de calendario y no pasa
  por `DataTimestamp` a propósito.

### Reglas en juego

- **RT-03** — La frase «92 de 120 … las otras 28 todavía no se consultaron» conserva su significado
  entero como `value="92 de 120"` + `denominator` = badge `28 sin consultar` con `tone="unknown"`
  (borde punteado, sin relleno, reloj). Las 28 no entran en ningún porcentaje ni en ninguna barra, y
  el gráfico sigue sin apilar: un apilado con `UNKNOWN` adentro es RT-03 dibujado al revés.
- **RT-01 / RT-02** — Cada `MetricCard` con su `DataTimestamp` en el `CardFooter`; cada ítem del
  bloque de atención con su «Última comprobación»; `TimezoneFootnote` al pie. Ninguna fecha vive sólo
  en un `title=`: los cinco `title=` de hoy pasan a `Tooltip`.
- **RT-04** — La diferencia entre «hay algo roto» y «no hay nada» pasa a ser estructural —bloque con
  botón contra línea— y no un borde ámbar contra uno neutro.
- **RT-05** — «Consultas a Google», «cambios de estado registrados», «URLs consultadas». El pie del
  gráfico sube a `Section.description` sin cambiar una palabra.
- **RT-07** — En régimen no se dibuja ninguna tarjeta en cero ni ningún control apagado: lo que no
  aplica no ocupa lugar.
- **RT-13** — La región viva del sondeo existe siempre, ahora en `PageIntro`.
- **RT-18** — El párrafo de `!canOperate` viaja con las cifras, como `description` de su `Section`.
- **Contrato §1** — Mueren tres `text-xl` (líneas 238, 371, 413) y el `@[250px]/card:text-3xl` de las
  cuatro tarjetas. `font-semibold` queda sólo en T2.
- **Contrato §6, reglas 2 y 6** — Un `h2` T3 por `Section`, ninguna vista con cero encabezados,
  y `grep "rounded-lg border" frontend/pages/Dashboard.tsx` pasa de cuatro a cero.

---

## V13 · Notificaciones — `/notifications` — [`frontend/pages/Notifications.tsx`](frontend/pages/Notifications.tsx)

**Veredicto**: reestructurar — **severidad** 3/5
**El problema en una línea**: la pantalla dedica sus primeros 240 px y 14 de sus 16 controles a
recortar una lista, y no dice ni una vez cuál es el recorte que trajo a la persona hasta acá.

### Qué pasa hoy

- **Tres filas de filtros, dieciséis controles, 240 px antes del primer aviso** (líneas 151-190). La
  fila «Tipo de aviso» tiene nueve opciones y desborda a dos renglones; una de ellas mide 35
  caracteres («Se perdió el acceso a una propiedad»). El contrato §5 lo resuelve de una: hasta cinco
  opciones es `toggle-group`, más de cinco es `select`.

- **El contador del menú y la página no hablan del mismo recorte.** La barra lateral apunta a
  `route('notifications')` a secas (`app-sidebar.tsx`, `available()`), o sea a «Todas»; el badge dice
  «1». Se hace clic en un «1» y se aterriza en una lista donde ese uno está en algún lado. El
  docstring del servidor ya tiene razón —«"Sin leer" no es un filtro más de la tabla: es una vista
  distinta de lo mismo, y la que trae a la persona acá», `apps/notifications/views.py:36-42`— pero
  el enlace no lo cumple.

- **El nivel casi no discrimina, y el filtro de nivel lo promete igual.** Seis de los ocho tipos son
  `ACTION` (`services.py`: `BATCH_FAILED`, `ACCESS_LOST`, `CREDENTIAL_INVALID`, `COVERAGE_DROP`,
  `FETCH_ERRORS`, `CRAWL_BLOCKED`) y sólo dos son `INFO` (`BATCH_FINISHED`, `EXPORT_READY`). Pero por
  volumen manda `INFO`: hay un `BATCH_FINISHED` por lote, por dominio y por día. O sea que el filtro
  de nivel es lo más útil de la pantalla **y está tercero, después de nueve chips**.

- **El aviso que pide una acción y el que sólo informa se ven casi iguales.** La única diferencia es
  la `variant` del badge (`destructive` contra `outline`) y la del botón (`default` contra `outline`,
  línea 386): misma caja, mismo borde, mismo peso de título, misma posición del control. Mientras
  tanto, **leído/sin leer se comunica con cuatro señales**: filo izquierdo de 4 px, `bg-muted/40`,
  badge «Sin leer» y `font-semibold` en el título (línea 357). Cuatro señales para la distinción
  menos consecuente y una sola para la más consecuente.

- **`font-semibold` en un título de 14 px** (línea 357) es el peso de T2 aplicado a un T4; y hace que
  la lista se reacomode visualmente al marcar algo como leído, sin que haya cambiado nada de fondo.

- **La página no tiene ningún encabezado visible.** El único `h2` es `sr-only` (línea 221) y las tres
  regiones de filtro están rotuladas por un `<p className="text-xs">` (línea 491). Misma carencia que
  V4, V5 y V12.

- **Nadie diseñó los 200 avisos.** `MAX_PER_ACCOUNT = 200` (`services.py:27`) y `_prune()` borra sólo
  los **leídos** que pasen de ese tope: el techo real es 200 leídos más los sin leer que haya. A 50
  por página y ~150 px por bloque, una página llena mide **~7.500 px** de `<ol>` plano, sin ningún
  punto de descanso, con la paginación abajo de todo.

- **`EXPORT_READY` es `INFO` y caduca.** «Podés bajarla desde la ficha del lote hasta el 26/08/2026»
  es el único aviso del producto con fecha de vencimiento, y se dibuja igual que «Un lote terminó».

### En qué orden debería mirarse

1. **`PageIntro`, una línea, y es la que contesta la pregunta**: `StatusBadge tone="attention"`
   «3 necesitan una acción» · «12 sin leer de 143». Con un enlace al recorte que sólo tiene las que
   piden algo. Si no hay ninguna sin leer, la línea es
   `StatusBadge tone="positive"` «No te queda nada sin leer».
2. **Los filtros, en una sola fila**: `ToggleGroup` [Todas · Sin leer (12)] · `Select` «Tipo de
   aviso» (nueve opciones) · `ToggleGroup` [Todos · Para saber · Necesita una acción]. `h2` T3 con
   `sr-only`, que es lo que el contrato autoriza para una región de filtros. De 240 px a ~40.
3. **La línea de conteo** `role="status"` con el total y la página. Ya está bien y se conserva.
4. **La lista, agrupada por día**: «Hoy», «Ayer», «jueves 14 de agosto», en `h3` T4, en la zona de la
   cuenta. Cada aviso, un `NoticeItem`.
5. **La paginación** y el pie de zona horaria.

**El recorte por omisión cambia**: la página abre en «Sin leer» cuando hay algo sin leer, y en
«Todas» cuando no. No hace falta ningún dato nuevo —`unread` ya viaja como prop— y es lo que el badge
del menú promete.

#### Cómo se agrupan

**Por día, dentro del recorte vigente.** Tres motivos, en orden de peso:

1. El servidor ofrece **un solo orden** y no deja cambiarlo: `sortable={'date': ('-created_at',)}`
   (`views.py:22-29`). Cualquier agrupación que no sea cronológica pelearía contra la consulta o
   pediría otra.
2. La pregunta que trae acá —«qué pasó mientras no miraba»— es temporal. Agrupar por tipo o por
   dominio contesta otra pregunta, y para ésas están los filtros.
3. Un encabezado de día es un renglón y convierte 50 filas indistinguibles en cinco o seis grupos
   que el ojo puede saltar.

Un grupo puede quedar partido entre dos páginas: la página 2 arranca con su propio encabezado de día
y eso es honesto, no un defecto. No hace falta ningún dato nuevo: `created_at` ya viaja y ya se
formatea en la zona de la cuenta.

#### Qué distingue al que pide una acción

Tres señales independientes, las tres sobreviven a la escala de grises:

1. **La forma del control.** El que pide una acción tiene un `Button` primario; el que sólo informa
   tiene un **enlace de texto**. Hoy son `default` y `outline`, dos botones de peso parecido. Un
   botón lleno contra un texto subrayado se distingue de reojo y a cualquier tamaño.
2. **El `StatusBadge`**, primero en la línea de metadatos: `tone="attention"` + `TriangleAlert` +
   «Necesita una acción», contra `tone="neutral"` + `Info` + «Para saber». Icono y después palabra
   (RT-04). Esto ya está bien y sólo cambia de pieza.
3. **La cuenta en `PageIntro`**, que es lo único que se lee sin bajar.

Y **leído/sin leer baja a una sola señal visual** —el relleno `muted` del `Item`— más su badge «Sin
leer» para la escala de grises y el lector de pantalla. Se van el filo de 4 px y el `font-semibold`:
el peso del título es constante porque el título no cambia de importancia al marcarlo.

#### Qué pasa con 200 avisos acumulados

- **El recorte por omisión** («Sin leer») es la respuesta principal: con 200 acumulados, lo que
  importa son los que faltan mirar, y eso ya lo sabe el servidor.
- **Los encabezados de día** parten la página en bloques leíbles.
- **La paginación del servidor se conserva tal cual** (`tableUrl()`, 50 por página, total anunciado
  por región viva). Lo que falta y hay que agregar es el **esqueleto**: hoy al cambiar un filtro la
  lista se redibuja sin ningún estado de carga (contrato §3.9). Filas de `Skeleton` que conservan la
  altura de lo que había.
- **Cada aviso pasa de ~150 px a ~76 px** al mudarse a `NoticeItem`: la fecha sube a la línea de
  metadatos, «Marcar como leída» se va al `DropdownMenu` cuando hay dos acciones, y el cuerpo queda a
  `max-w-prose` en dos renglones. Una página llena baja de ~7.500 px a ~4.000.
- **Lo que no hago**: no propongo agrupar por tipo, ni colapsar los repetidos, ni un «marcar leídos
  los de más de 30 días». `dedupe_key` ya impide el duplicado por hecho (`services.py:56-73`) y el
  `_prune` ya acota el histórico; inventar otra poda en la interfaz sería esconder avisos que el
  servidor decidió conservar.

#### La relación con el contador del menú

Tres reglas, y una de ellas es un arreglo de una línea:

1. **El badge cuenta los sin leer de toda la cuenta**, nunca los de la página ni los del filtro
   vigente (`unread_count`, `services.py:76`, compartido por `apps/core/inertia.py:44`). Se conserva.
2. **La entrada del menú tiene que llevar al recorte que el badge nombra**: `?unread=1` cuando
   `unread > 0`. Eso es un cambio de **destino** de una entrada, no de estructura, y el propio aviso
   de `app-sidebar.tsx` lo autoriza por escrito («Permitido: cambiar el CONTENIDO —entradas, rótulos,
   textos, íconos, destinos—»). No es tocar el armazón.
3. **Entrar no vacía el número.** Es una decisión deliberada del producto y se conserva
   (`apps/notifications/views.py:1-7`). Lo que cambia es dónde se explica: hoy son dos renglones de
   metaexplicación en la `description`, arriba de todo, antes de cualquier contenido. La
   `description` queda en «Lo que pasó con tus dominios mientras no mirabas.» y la aclaración se
   muda al `EmptyState` de «no te queda nada sin leer» y al `Tooltip` de «Marcar todas como leídas»,
   que es donde la pregunta aparece de verdad.

#### ¿Página o panel? — Página. Y queda decidido por escrito.

Recorriendo el árbol del contrato §4 en orden:

1. *¿Es destructivo o irreversible?* No: marcar leído no borra nada (`mark_all_read`,
   `services.py:89-96`).
2. *¿Tiene identidad propia, URL propia, o alguien puede llegar desde afuera —un enlace, una
   notificación, la barra lateral—?* **Sí, las tres.** Tiene ruta publicada (`config/urls.py:82`,
   `apps/core/routes.py:49`), tiene entrada propia en la barra lateral con su contador, y sus
   recortes son enlazables y compartibles (`?unread=1&kind=…&level=…&page=2`). **El árbol se para
   acá: página propia.**

Y tres razones que lo cierran, para que no se reabra:

- **Un panel del encabezado no se puede construir sin romper el armazón.** El `actions` de
  `SiteHeader` es por página y de una línea, así que una campana ahí existiría sólo en las pantallas
  que se acuerden de pasarla —justo lo contrario de lo que un panel global significa—. Un disparador
  global habría que meterlo en `site-header.tsx`, que es intocable.
- **La barra lateral ya *es* el panel.** La entrada con su badge es el punto de entrada global que un
  panel daría. Construir el panel obligaría a borrar la entrada del menú para no tener dos puertas a
  lo mismo: otra vez, romper el armazón.
- **No entra.** Un `Sheet` es «el detalle de una fila» (§4, punto 4) y este contenido es una lista
  paginada de hasta 200 con tres filtros y su propia paginación. El contrato prohíbe explícitamente
  «cualquier cosa con una tabla adentro» en un `Dialog` y los formularios largos en un `Sheet`; el
  estado enlazable de RT-09 no tendría dónde vivir.

**Entonces: no hay panel. El contador del menú es el único punto de entrada global, y su enlace lleva
al recorte que nombra.**

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `description` de dos renglones que explica que entrar no vacía el contador (línea 117) | Una oración; la aclaración se muda al `EmptyState` de «sin leer» y al `Tooltip` de «Marcar todas» | `AppLayout.description` + `Tooltip` | Una regla del producto explicada antes de cualquier contenido es un manual, no una bajada |
| — (no existe hoy) | **`PageIntro`**: «3 necesitan una acción · 12 sin leer de 143», con enlace al recorte | `PageIntro` + `StatusBadge` | Es lo único que hace falta leer al entrar, y hoy hay que contarlo a mano bajando la lista |
| `FilterRow` «Qué se muestra», 2 chips (líneas 151-158) | `ToggleGroup` de 2 | `toggle-group` ✅ | ≤5 opciones (§5) |
| `FilterRow` «Tipo de aviso», 9 chips en dos renglones (líneas 160-174) | `Select` | `select` ✅ | >5 opciones (§5). Recupera ~120 px y saca el renglón que desborda |
| `FilterRow` «Nivel», 3 chips (líneas 176-190) | `ToggleGroup` de 3 | `toggle-group` ✅ | ≤5 opciones. Sube a la misma fila que los otros dos |
| Las tres `<section>` con rótulo en `<p className="text-xs">` (línea 491) | Una `Section` con `h2` T3 `sr-only` y los tres controles en una fila | `Section` (§3.2) | La página deja de tener cero encabezados; el nivel sigue existiendo aunque el dibujo se oculte |
| Línea de conteo `role="status"` (líneas 207-213) | Se queda tal cual | — | RT-09: el total siempre a la vista y anunciado |
| `<ol>` plano de bloques (línea 225) | La misma `<ol>`, con `h3` T4 por día | `Section` + `h3` | Cinco o seis puntos de descanso en lugar de 50 filas indistinguibles |
| El `<li>` del aviso, `rounded-lg border border-l-4 p-4` (líneas 315-321) | `NoticeItem` sobre `Item` | `item` ⛔ instalar | Es la misma anatomía que el ítem del bloque de atención de V0, dibujada distinta. De ~150 px a ~76 |
| El filo izquierdo de 4 px + `bg-muted/40` + `font-semibold` para «sin leer» | Sólo el relleno `muted` del `Item`, más el badge «Sin leer» | `Item tone="muted"` | Cuatro señales para la distinción menos consecuente. Y `font-semibold` es el peso de T2 (§1) |
| Badge de nivel `variant={needsAction ? 'destructive' : 'outline'}` (líneas 335-341) | `StatusBadge tone="attention" \| "neutral"` | `StatusBadge` (§3.7) | Un solo badge en todo el producto, con un juego cerrado de cinco tonos |
| Botón de acción `variant={needsAction ? 'default' : 'outline'}` (línea 386) | `Button` primario si es `ACTION`; **enlace de texto** si es `INFO` | `Button` / `<Link>` | La señal más fuerte de la lista pasa a ser la forma del control, que sobrevive a la escala de grises |
| «Marcar como leída» como botón en cada fila (líneas 399-410) | `DropdownMenu` de la fila **cuando hay dos acciones**; botón suelto cuando es la única | `dropdown-menu` ✅ (§4, punto 10) | Hasta 50 botones fantasma por página. Con una sola acción sigue siendo un botón, que es la regla |
| Fecha al pie del bloque (líneas 374-382) | Línea de metadatos, arriba, junto a los badges: «hace 7 horas · Leída anteayer» | `DataTimestamp`, T6 | Es lo que se escanea para decidir si el aviso todavía importa; abajo se lee último |
| Los tres `EmptyState` de `EmptyList` (líneas 417-485) | Los mismos tres, sobre `@shadcn/empty` | `empty` ⛔ instalar | El razonamiento es correcto y se conserva entero; cambia el adentro, no la firma |
| — (no existe hoy) | Filas de `Skeleton` al cambiar de filtro o de página | `skeleton` ✅ (§3.9) | Hoy la lista se redibuja sin ningún estado de carga |
| `nav` de paginación (líneas 236-258) | Se queda | — | RT-09 |
| Enlace de la entrada del menú | `?unread=1` cuando `unread > 0` | destino de `app-sidebar.tsx` | Un badge que dice «1» tiene que aterrizar en ese 1 |

**El dato que convendría pedir**: `notifications.unread_action` junto a `notifications.unread` en
`apps/core/inertia.py::_notifications()`. Es un `count` más sobre el índice
`['account', 'read_at']` que ya existe, y es lo que hace que `PageIntro` pueda decir «3 necesitan una
acción» sin que la página lo derive de una muestra de 50 filas —que sería una cifra falsa en cuanto
haya más de una página—.

### Componentes compartidos que necesita

- **`NoticeItem`** — la pieza nueva descrita en V0. Acá es el aviso; allá es el ítem del bloque de
  atención. Una sola anatomía para las dos.
- **`PageIntro`** (§3.1) — la línea de estado con el conteo de lo que pide acción.
- **`Section`** (§3.2) — la región de filtros (con `h2` `sr-only`) y la lista.
- **`StatusBadge`** (§3.7) — nivel y estado de lectura, los dos con icono + palabra.
- **`EmptyState`** sobre `empty` (§3.8) — los tres vacíos, sin cambiar su firma ni sus textos.
- **`Skeleton`** (§3.9) — la carga que hoy no existe.
- **`DropdownMenu`** (§4) — las acciones secundarias de la fila.
- **`Select` + `ToggleGroup`** (§5) — los filtros.

### Qué no tocar

- **La decisión de los dos controles separados** (líneas 281-292): seguir el enlace no marca leído.
  «Si el enlace marcara leído de paso, un clic exploratorio borraría la única señal de que quedaba
  algo pendiente.» Es correcto y se conserva.
- **Los tres vacíos distintos** de `EmptyList` (líneas 417-485): «no tenés ninguno», «no te queda
  nada sin leer» y «el filtro no encontró» son tres cosas distintas y hoy están bien separadas. Es lo
  mejor argumentado del archivo.
- **Los filtros como enlaces de verdad** armados con `tableUrl()`, no como manejadores de clic: se
  abren en otra pestaña, se guardan y el botón Atrás funciona.
- **El nombre accesible único por botón** (`<span className="sr-only"> — {item.title}</span>`,
  líneas 394 y 408), que empieza por el texto visible para que el comando de voz lo alcance.
- **Que «Marcar todas» exista sólo si hay algo que marcar** (línea 128), sin versión deshabilitada
  con globo (RT-07), y que no pida confirmación (marcar leído no destruye nada).
- **El `role="group"` con `aria-labelledby` en vez de `fieldset`** (líneas 494-499): son enlaces, no
  campos.
- **Que el contador salga de la cuenta y no de la vista** (`apps/core/inertia.py:44-60`).
- **`dedupe_key`, `MAX_PER_ACCOUNT` y `_prune`**: la política de retención es del servidor y la
  interfaz no la duplica.

### Reglas en juego

- **RT-09** — Paginación de servidor, 50 por página, total siempre a la vista y anunciado, filtros y
  página en la cadena de consulta. Todo eso ya está y se conserva; se agrega el esqueleto que falta.
- **RT-04** — Nivel = icono + palabra + **forma del control**. Leído/sin leer = relleno + badge con
  su punto. Las dos distinciones sobreviven en escala de grises y dejan de competir entre sí.
- **RT-01** — La fecha sube a la línea de metadatos, en `DataTimestamp`, con el absoluto siempre
  disponible; `TimezoneFootnote` al pie. La aclaración de la caducidad de una exportación viaja en el
  cuerpo del aviso, escrita por el servidor.
- **RT-05** — Los ocho textos ya cumplen: informan lo que Google contestó y no prometen nada.
  `COVERAGE_DROP` es el único que habla de indexación y lo hace en pasado y sobre lo informado.
- **RT-07** — «Marcar todas como leídas» no existe en cero. Ningún control apagado.
- **RT-08 / §3.10** — La confirmación de marcar leído sigue siendo el redibujado en el lugar
  (`preserveScroll`), nunca un toast.
- **Contrato §1** — Muere el `font-semibold` del título del aviso (línea 357); el título es T4 y su
  peso no cambia con el estado de lectura.
- **Contrato §4** — El árbol se para en el punto 2: página propia, sin panel.
- **Contrato §5** — Nueve opciones de tipo → `Select`; dos y tres opciones → `ToggleGroup`.
- **Contrato §6, regla 24** — Los filtros son todos `size="sm"` hoy y así se quedan: no se mezclan
  con `size="default"` en la misma fila.

---

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

1. **Una vista tiene una pregunta y una acción.** Regla verificable de un vistazo: contar los botones
   primarios visibles sin hacer scroll. Si hay dos, una de las dos es la equivocada; si hay cero
   donde la pantalla describe un problema, falta la que importa. En V0 hoy hay exactamente uno y es
   el que no corresponde: se ofrece «Agregar dominio» a alguien que tiene un dominio esperando
   autorización. Sube al contrato como regla 31.

2. **El estado sano y el estado roto no se dibujan con la misma caja.** Sin nada que hacer, el bloque
   de estado es **una línea** (`PageIntro`); con algo que hacer, es una `Section` con lista y un
   botón. La diferencia tiene que sobrevivir a la escala de grises y a una miniatura de la captura.
   Hoy V0 usa la misma caja con otro tinte, y eso es RT-04 aplicado a la disposición. Vale igual para
   V4, V6 y V8.

3. **El mismo número no se dibuja dos veces en la misma pantalla.** V0 tiene 92 / 120 / 28 / «1
   dominio» / la fecha en una frase de 20 px y otra vez en una tarjeta de 30 px, a 200 px de
   distancia. Regla: si un dato vive en una `MetricCard`, no vive además en prosa; la prosa que lo
   acompañaba se convierte en el `denominator` o el `note` de esa tarjeta. Es grep-eable: el mismo
   `formatNumber(x)` dos veces en una vista es la señal.

4. **`Item` es la fila del producto, y `NoticeItem` es su forma con estado.** Aviso, ítem de
   atención, lote reciente, sesión, clave de API, paso del recorrido: todas son [badge] [título con
   enlace] [motivo en muted] [fecha] [una acción]. Hoy esa fila está dibujada a mano al menos cinco
   veces con cinco resultados distintos. `NoticeItem` se agrega al contrato §3.6 y las cinco pasan
   por ahí.

5. **`CardTitle` es un `<div>`.** La primitiva (`ui/card.tsx:36`) no emite ningún encabezado. Si una
   `Card` es una sección de la página, su título tiene que ir por `asChild` a un `h2`, o la `Card`
   tiene que vivir dentro de una `Section` que lo aporte. Hoy V0 tiene cinco títulos de tarjeta que
   no son encabezados, y la regla 2 del contrato («todo `h2` es T3») no lo detecta porque no hay
   `h2` que revisar. Afecta a **todas** las vistas con tarjetas. Sube al contrato §3.3.

6. **Una `Section` con exactamente una `Card` adentro es un marco doble.** O la `Card` se disuelve
   —el gráfico de V0— o la `Section` desaparece. Se decide por si el contenido tiene borde propio.

7. **El párrafo que impide leer mal un gráfico va antes del gráfico**, como `Section.description`, no
   como pie. Leerlo después de haber mirado el dibujo llega tarde: la interpretación ya se hizo.

8. **El destino de una entrada del menú es contenido, no armazón.** `app-sidebar.tsx` prohíbe
   reestructurar y **autoriza por escrito** cambiar destinos. Un badge que dice «1» y aterriza en una
   lista de 200 es la desconexión más barata de arreglar del producto, y arreglarla no toca el
   armazón. Regla general: **todo contador lleva al recorte que nombra.**

9. **`DataTable` es un contrato, no un `<table>`.** La regla 13 del contrato debería leerse: *toda
   lista que pueda pasar de 50 filas pagina en el servidor, muestra su total y lleva su estado en la
   URL; usa `DataTable` además cuando sus filas sean celdas comparables.* V13 cumple lo primero sin
   lo segundo, y su docstring (líneas 64-83) tiene razón: un aviso es un bloque, no un juego de
   celdas. Sin esta corrección, la regla obliga a convertir en tabla una lista que no lo es.

10. **Agrupar por día es la forma barata de hacer leíble una lista larga sin pelear con el orden del
    servidor.** Encabezados `h3` T4, en la zona de la cuenta, aceptando que un grupo se parta entre
    páginas. Vale para V13, V9 y V12.

11. **Dos datos que convendría pedirle al servidor.** Ninguno inventado; los dos son un `count` o un
    id sobre consultas que ya se hacen:
    - `notifications.unread_action` junto a `notifications.unread` (`apps/core/inertia.py:44`), para
      que el contador del menú y la primera línea de V13 digan lo mismo.
    - `domains.only_id` en `apps/web/dashboard.py::_domains()`, no nulo sólo cuando `total === 1`,
      para que el estado «hay dominio pero no hay URLs» pueda ofrecer el sitemap de *ese* dominio en
      vez de mandar al listado a elegir entre uno.

12. **Los `title=` son deuda distribuida.** Sólo entre V0 y sus componentes hay cinco
    (`section-cards.tsx:125, 133, 192, 250` y `BatchStateBadge.tsx:96`), todos con ayuda real adentro
    y ninguno alcanzable con el teclado ni existente en táctil. Migrarlos a `Tooltip` es mecánico y
    conviene hacerlo de una sola pasada, junto con la unificación de los tres badges bajo
    `StatusBadge`: son el mismo cambio.

13. **Una región viva vacía sigue ocupando su hueco.** El `<p role="status">` de V0 (líneas 503-505)
    tiene que existir siempre para que se anuncie, pero su `gap-3` se paga aunque el texto esté
    vacío. La solución no es borrarla: es mudarla a un lugar donde su hueco sea parte de la
    disposición —`PageIntro`—, en vez de dejar un renglón fantasma entre dos bloques.
