# Grupo B · Dominios (V4, V5, V6)

Una corrección de entrada, porque cambia el diagnóstico: **V4 ya se rinde con `DataTable`**
(`Domains/Index.tsx:7,216`). La tabla del brief —«Cobertura, Lotes y Dominios no usan `table`»— sale
de mirar los imports de `@/components/ui/`, y V4 llega a `ui/table` por adentro de `DataTable`. Las
«nueve tarjetas» medidas son, en realidad, seis chips de filtro redondeados, el contenedor de la
tabla y dos `Alert`. V4 no tiene el problema de forma que tienen V7 y V9: tiene un problema de
**densidad de fila** y de **jerarquía semántica**. La propuesta lo trata como lo que es.

---

## V4 · Dominios — `/domains` — [`frontend/pages/Domains/Index.tsx`](frontend/pages/Domains/Index.tsx)

**Veredicto**: reestructurar — **severidad** 3/5
**El problema en una línea**: la tabla es correcta, pero cada fila mide 114 px porque una celda
guarda tres cosas apiladas —badge, párrafo y botón— y ese párrafo es del estado, no de la fila.

### Qué pasa hoy

- **Las filas miden ~114 px para cinco datos cortos.** Medido sobre `v4-domains-index.png`: la
  primera va de y≈275 a y≈388, la segunda de y≈388 a y≈503. Con la densidad estándar del contrato
  (`py-3`, celdas de una línea) la misma fila entra en ~48 px. Es la diferencia entre ver 7 dominios
  y ver 18 en la misma pantalla.
- **La celda «Acceso» apila tres piezas**: `AccessStateBadge`, un `<p className="max-w-56 text-xs">`
  y un `Button size="sm"` (`Index.tsx:409-430`). El párrafo sale de `accessStateHelp(state,
  canOperate)`: es texto **del estado**, no de la fila. Con doce dominios, ocho renglones repiten
  palabra por palabra «Falta autorizar la cuenta de servicio en Search Console». Y con
  `can_operate` en falso devuelve la **misma** frase para los cinco estados
  (`AccessStateBadge.tsx:103`): trescientas copias de la misma oración de 24 palabras.
- **Cero `h2` y cero `h3`, y no es culpa de la página.** La región de filtros se rotula con un
  `<p id="filter-state" className="text-xs">` (`Index.tsx:346`) y la tabla no tiene título. La causa
  de fondo la comparte con V5: `CardTitle` rinde un `<div>` (`ui/card.tsx:36`), así que una vista
  puede tener títulos visibles y ningún encabezado real.
- **Con la credencial caída, el filtro y las filas se contradicen.** El chip dice «Operativo»
  —`accessStateLabel('OPERATIONAL')`— y las filas que devuelve dicen «En espera», porque
  `AccessStateBadge` reemplaza la etiqueta cuando `canOperate` es falso
  (`AccessStateBadge.tsx:77-84`). Filtrar por un estado y recibir filas que dicen otro se lee como
  un error del producto, no como una advertencia.
- **Tres banners posibles apilados arriba**: `AccountNotices` (lo pone `AppLayout`),
  `ServerErrorNotice` de `errors.check` y el `Alert` propio «Antes de agregar un dominio…»
  (`Index.tsx:200-210`). Los dos últimos llevan al mismo `/settings` que el primero. Es el «no sé
  adónde mirar» del encargo, en su forma más literal.
- **La fecha del ciclo vive adentro de una frase.** `… · último ciclo anteayer` cierra la prosa de
  la celda de cobertura (`Index.tsx:532-533`). RT-02 pide que la fecha del dato esté en su propia
  columna: hoy no se puede ordenar por ella ni leerla en diagonal.

### En qué orden debería mirarse

1. ¿Hay algo que bloquee la cuenta entera? — un único `AccountNotice`.
2. ¿Cuántos dominios tengo y cuáles piden una acción? — el conteo de `DataTable`, el orden por
   atención (`_ATTENTION`, `views.py:35`) y el filtro por estado.
3. Fila: **quién es** — el hostname, enlazado.
4. **En qué estado está y de cuándo** — badge + su columna de fecha, juntas y consecutivas.
5. **Cuánto sabemos de él y de cuándo** — `92 de 120` + su columna de fecha de ciclo.
6. **Qué puedo hacerle** — un menú al final de la fila, no un botón en el medio.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `Alert` «Antes de agregar un dominio…» (`Index.tsx:200-210`) | se borra; la frase sobrevive en el rótulo del botón y en la descripción del `EmptyState` | — | `AccountNotices` ya rinde el mismo aviso con la misma acción, arriba y sin que la página lo pida |
| `DomainAddedNotice` (`Index.tsx:298`) | se queda arriba de la tabla | `Alert role="status"` | es una instrucción, no una felicitación: bien que no se desvanezca |
| `AccessStateFilter` + `FilterOption` (`Index.tsx:335-396`) | `Section` con `h2` `sr-only` | `ToggleGroup type="single"` con `ToggleGroupItem asChild` + `<Link>` | seis `Button size="sm"` sueltos son seis controles sin forma común; `toggle-group` está instalada y `asChild` conserva los enlaces reales |
| `property_type_label` bajo el hostname (`Index.tsx:107`) | `HoverCard` sobre el hostname; el dato vive en la ficha | `hover-card` (instalar) | es igual en casi todas las filas y sólo importa cuando falla `PROPERTY_NOT_FOUND`; el contrato §4 nombra este caso exacto |
| Párrafo de ayuda de la celda «Acceso» (`Index.tsx:421-423`) | `Tooltip` del badge | `tooltip` | el texto es del estado, no de la fila; hoy se repite una vez por renglón |
| `CheckAccessButton` en la celda (`Index.tsx:439`) | última columna, sin encabezado visible | `DropdownMenu` (`RowActions`) | son 2+ acciones por fila (comprobar, ver cobertura, ver sitemaps, ver lotes); §3.5 manda menú |
| Prosa de la celda de cobertura (`Index.tsx:521-536`) | misma celda, dos líneas: `92 de 120` en T5 `tabular-nums` + `62 indexadas · 30 no indexadas` en T6 | `CoverageFigure` | el checklist V4 exige el denominador **en la misma celda**; la prosa impide alinear a la derecha y es la mitad de los 114 px |
| `… último ciclo <fecha>` dentro de esa prosa (`Index.tsx:532`) | columna propia «Último ciclo» | columna de `DataTable` con `DataTimestamp` | RT-02: la fecha del dato en su columna, ordenable, sobrevive a una captura |
| Párrafo fijo sobre `UNKNOWN` bajo la tabla (`Index.tsx:252-256`) | se queda tal cual | `<p max-w-prose>` | la confusión que evita vuelve cada vez que se mira esa columna |

**Las siete columnas resultantes**, en este orden: Dominio (única celda con enlace, `wrap-anywhere`)
· Acceso (`StatusBadge` solo) · Comprobado (`DataTimestamp`) · Cobertura con dato / total (numérica,
a la derecha, encabezado también a la derecha) · Último ciclo (`DataTimestamp`) · Avisos (icono +
palabra, T6) · acciones (`DropdownMenu`). Cada estado queda pegado a su fecha, que es lo que piden
RT-01 y RT-02, y las dos columnas numéricas cumplen la regla 14 del contrato.

**La unidad de la lista es la fila de tabla, no la tarjeta.** Cinco razones, y ninguna es «porque
sí»: (1) la pregunta de la vista es comparativa —«¿cuál de mis dominios pide algo?»— y comparar es
para lo que existen las columnas; (2) hay **dos** fechas y RT-02 pide cada una en su columna, cosa
que una tarjeta no puede hacer; (3) hay dos datos numéricos y la regla 14 pide `text-right
tabular-nums`, que sólo significa algo alineado con los de arriba y los de abajo; (4) a 300
dominios hace falta paginación de servidor, total a la vista y orden, que `DataTable` ya da (RT-09);
(5) el contrato pasa cinco de las seis listas del producto por `DataTable`, y una grilla de tarjetas
acá sería la séptima forma. El contraargumento previsible —«con dos dominios una tabla parece
exagerada»— se contesta solo: la cantidad de filas no es una entrada de diseño. Una lista que cambia
de forma en N son dos diseños que mantener y una sorpresa en N+1.

**Un dominio no operativo por culpa de la cuenta (R-F).** Tres cambios, ninguno de los cuales borra
el estado del dominio:

1. El badge conserva **su palabra y su icono** (`Operativo`, `CircleCheck`) y cambia de **tono** a
   `unknown`: borde punteado, sin relleno, muted. Es la familia visual que el contrato ya reserva
   para «todavía no preguntamos», y acá dice exactamente eso: no es que el dominio esté roto, es que
   hoy no podemos confirmarlo. Hoy en cambio se le pisa la etiqueta con «En espera», que rompe la
   correspondencia con el chip del filtro y le atribuye al dominio un problema de la cuenta.
   `tone` e `icon` son props separadas en la firma de `StatusBadge` (§3.7), así que
   `tone="unknown"` con el icono del estado ya es expresable sin tocar el contrato.
2. La columna «Comprobado» hace el resto del trabajo: con la credencial caída esa fecha envejece
   sola y la fila queda diciendo «Operativo, hace once días» sin una palabra de más.
3. El menú de la fila **pierde** las acciones que llaman a Google —no las deshabilita— y conserva
   las de navegación (RT-18). La única acción que resuelve el problema está una vez, arriba, en el
   `AccountNotice`. Y el `caption` de `DataTable`, que ya nombra el filtro vigente, nombra también
   esta condición para quien no ve el borde punteado.

**1, 12 y 300 dominios.** Con 1 no cambia nada: una tabla de una fila es una tabla de una fila, y el
recién agregado ya trae su `Alert role="status"` con el paso siguiente. Con 12 la vista está en su
punto de diseño: un solo tramo, filtro visible, orden por atención arriba. Con 300 lo que se rompe
**no** es la tabla —pagina de a 50 en el servidor con el total a la vista— sino el **acceso a un
dominio concreto**: no existe búsqueda por hostname (`DOMAINS` en `views.py:53-62` sólo declara
`access_state` como filtro), así que encontrar `tienda.ejemplo.com` es ordenar por nombre y caminar
seis páginas. Es lo único que V4 le pide al servidor: un filtro `q` sobre `hostname`, rendido con
`input-group` (§5). Si no entra en el alcance, hay que decirlo con todas las letras en vez de
descubrirlo con el cliente número trece.

### Componentes compartidos que necesita

- **`FilterChips`** — `{ label: string; param: string; options: { value: string; label: string }[];
  current: string; page: TablePage<unknown>; allLabel?: string }`. `ToggleGroup type="single"` con
  `ToggleGroupItem asChild` envolviendo un `<Link>` a `tableUrl(page, { [param]: value, page: null })`,
  dentro de una `Section` con `h2` `sr-only`. Reemplaza `AccessStateFilter` + `FilterOption` acá y
  sus gemelos en V7 y V9: los tres filtros de estado del producto pasan a ser uno.
- **`RowActions`** — `{ label: string; items: { label: string; href?: string; onSelect?: () => void;
  destructive?: boolean }[] }`. `DropdownMenu` en la última celda, ítem destructivo último tras un
  `DropdownMenuSeparator` y siempre a través de `ConfirmDestructive`. El `label` da el nombre
  accesible («Acciones de ejemplo.com»), que es lo que hoy resuelve el `<span className="sr-only">`
  de `CheckAccessButton`.
- **`CoverageFigure`** — `{ coverage: Coverage | null; variant: 'cell' | 'metric' }`. Los **tres**
  casos que hoy vive `CoverageCell` (`Index.tsx:477-537`) —sin URLs descubiertas, con URLs y sin
  dato, con dato— dichos igual en la celda de V4, en la `MetricCard` de V6 y en el resumen de V7.
  Es literalmente el pedido de «estandarizalos»: hoy los mismos tres números se escriben distinto en
  tres lugares.
- **`StatusBadge`** (del contrato) — `AccessStateBadge` conserva su mapa, sus etiquetas y sus
  docstrings y pasa a rendir a través de él, con el `help` en `Tooltip` en vez de `title=`.
- **`Section`**, **`EmptyState`** sobre `@shadcn/empty`, **`HoverCard`** (instalar).

### Qué no tocar

- El orden por omisión `_ATTENTION` (`views.py:35-45`): arriba lo que espera una acción. Es la
  decisión que hace útil la vista a 300 dominios y está bien argumentada en el servidor.
- El filtro como **enlaces de verdad** con el estado en la querystring: es lo que hace que
  `/domains?access_state=ACCESS_LOST` —la dirección del aviso `DOMAINS_ACCESS_LOST`— llegue con el
  filtro puesto. Cambiarlo a un `Select` con navegación por JS rompería ese enlace y el botón Atrás.
- Los **dos** estados vacíos distintos según `total` contra el filtro (`Index.tsx:222-244`):
  «no tenés dominios» y «el filtro no encontró» tienen salidas distintas.
- El enlace en el **nombre** y no en la fila entera (`Index.tsx:101-106`), con su docstring.
- Los tres casos de `CoverageCell` y su regla de mirar `known_urls` y no la fecha del ciclo
  (`Index.tsx:497-505`).
- `DataTable` entero: ya cumple §3.5 punto por punto (conteo en región viva, `caption` con el filtro,
  `aria-sort`, encabezado fijo, esqueleto que conserva la altura, paginación de servidor).

### Reglas en juego

- **RT-02** — cada estado con su fecha en **columna propia**: la del acceso ya está; la del ciclo
  hay que sacarla de adentro de la frase.
- **RT-03** — el denominador `92 de 120` delante de los conteos, en la misma celda, sin pasar el
  mouse. Se conserva; sólo cambia de prosa a dos líneas.
- **RT-04** — icono + palabra. Con la credencial caída los cinco estados siguen distinguiéndose en
  escala de grises porque conservan su icono y su palabra; lo que cambia es el tono.
- **RT-09** — 50 por página, servidor, total visible. Ya está.
- **RT-18 / R-F** — ninguna fila se presenta como operativa sin credencial, y las acciones que
  llaman a Google se **reemplazan**, no se apagan.
- **Contrato §6 reglas 2, 14, 16, 19, 24** — un `h2` por sección aunque sea `sr-only`; columnas
  numéricas a la derecha con su encabezado; fecha por columna; la fila no es clickeable entera; no
  se mezclan `size="sm"` y `size="default"` en la misma fila de controles.

### Movimiento

Nada de esta vista se anima. Filtrar y paginar son navegaciones repetidas y alcanzables por teclado:
el único aviso es el intercambio por el esqueleto (§3.12). El `Tooltip` del badge entra en 150 ms
escalando desde su disparador, y **a partir del segundo abre sin retardo y sin animación**: recorrer
una columna de doce badges con 500 ms de espera en cada uno hace lenta a toda la tabla. El
`HoverCard` del hostname abre con ~300 ms de retardo —para no dispararse al pasar de largo— y cierra
al instante.

---

## V5 · Alta de dominio — `/domains/new` — [`frontend/pages/Domains/Create.tsx`](frontend/pages/Domains/Create.tsx)

**Veredicto**: reestructurar — **severidad** 3/5
**El problema en una línea**: la misma pregunta —«¿entendí bien lo que escribiste?»— se contesta dos
veces, a 500 px de distancia y con dos tipografías distintas.

### Qué pasa hoy

- **974 px y 51 nodos para dos campos.** Un `<input>` y un grupo de dos radios, repartidos en dos
  `Card` y un `Alert`, dentro de un `<form className="max-w-2xl space-y-6">` (`Create.tsx:89`) que
  duplica el ritmo de 24 px que `<main>` ya da.
- **La normalización está contestada dos veces.** «Vamos a guardarlo como `ejemplo.com`» vive bajo el
  input (`Create.tsx:110-119`); «Vamos a consultar esta propiedad: `sc-domain:ejemplo.com`» vive
  ~500 px más abajo, al pie de la segunda tarjeta (`Create.tsx:186-193`). Son dos mitades de la misma
  respuesta y están en dos tarjetas distintas: para verificar lo que escribiste tenés que mirar dos
  lugares y unirlos de memoria.
- **El eco se anuncia por tecla.** El `<p aria-live="polite">` cambia con cada pulsación, así que
  escribir `https://Ejemplo.com/` produce veinte anuncios en un lector de pantalla. Y comparte
  posición con el `FieldError` que va inmediatamente debajo (`Create.tsx:120`): dos mensajes en el
  mismo lugar, uno de los cuales sí es un error.
- **Cinco `<code>` con cuatro juegos de clases distintos** en este archivo y el de la ficha:
  `bg-muted/60 rounded px-1` (114), `bg-muted/60 rounded px-1.5 py-0.5 text-xs` (174),
  `bg-muted rounded px-2 py-1 text-sm break-all` (189), `bg-muted/60 rounded px-2 py-1 text-sm
  break-all` (211) y otra vez esta última en `Show.tsx:372`. Es «las de un lado son distintas de las
  del otro» en su versión más chica y más repetida.
- **Las dos opciones de forma son tarjetas dibujadas a mano dentro de una tarjeta**:
  `<Label className="… rounded-lg border p-4 transition-colors">` (`Create.tsx:155`). Rompe la regla
  6 del contrato (ninguna página define su propio borde de tarjeta), la regla 8 (`Card` dentro de
  `Card`) y la 26 (la transición no tiene variante `motion-reduce:`). Y la primitiva `field`, que es
  exactamente para esto, está instalada y sin usar en ninguna página.
- **Cero `h2` y cero `h3`**, por la misma causa que V4: `CardTitle` es un `<div>`.

### En qué orden debería mirarse

1. **Qué estoy por hacer y qué hace falta antes** — la bajada de la página: el sitio ya tiene que
   estar dado de alta en Search Console.
2. **Cuál es el sitio** — el campo.
3. **Con qué forma lo diste de alta allá** — la decisión que puede arruinar todo lo que sigue. Es la
   jerarquía que el checklist fija para esta vista y hoy la tiene el campo, que es lo obvio.
4. **Qué vamos a guardar y qué vamos a consultar** — una sola respuesta, en un solo bloque.
5. **Qué falta después** — la dirección de la cuenta de servicio, para copiar ahora.
6. Dar de alta.

### Página o modal

**Página.** El árbol del contrato se recorre en orden y se para en el primer sí, y acá para en el
paso 2: `/domains/new` tiene URL propia, `Show.tsx:337` le enlaza desde otra vista («Dar de alta la
otra forma») y el servidor la usa como destino de redirección. Nunca se llega al paso 3, que sería
el que habilita un `Dialog` por tener ≤5 campos.

Dos razones más, por si el árbol no alcanzara. **Mecánica**: el error de validación se resuelve con
`redirect('domain.new')` y errores en sesión (`views.py:161-171`), y el éxito con
`redirect('domains') + '?created='`. Un `Dialog` cuyo error de validación te cierra el diálogo y te
deposita en una página está roto; hacerlo funcionar exigiría cambiar el contrato de redirección del
servidor a cambio de nada. **De contenido**: la decisión de la vista es comparar dos opciones que
tienen que verse **a la vez y explicadas** —el checklist lo pide con esas palabras y `views.py:127`
lo argumenta—. En un `Dialog` de ~500 px las dos opciones se apilan a ~400 px y comparar exige
scrollear adentro del diálogo, que es justo lo que no se puede pedir en el momento de decidir.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `<form className="max-w-2xl space-y-6">` (`Create.tsx:89`) | tres `Section` sueltas bajo el `gap-6` de `<main>`, el `<form>` sin `space-y` propio | `Section` | el ritmo vertical ya está resuelto en el armazón; un `space-y` alrededor es un segundo mecanismo que después diverge |
| `Card` «El dominio» (`Create.tsx:90-129`) | `Section` «El sitio» con `FieldSet` / `Field` / `FieldLabel` / `FieldDescription` / `FieldError` | `field` | una `Card` con un solo `<input>` adentro es un borde alrededor de un campo |
| Eco «Vamos a guardarlo como…» (`Create.tsx:110-119`) | al bloque único de vista previa | `DescriptionList` de dos `LabelValue` | hoy la respuesta está partida en dos tarjetas; junta, se lee de un vistazo y deja de competir con el `FieldError` |
| `<Label className="rounded-lg border p-4">` por opción (`Create.tsx:152-181`) | `Field` de tipo elección, con su ejemplo en `CodeChip` | `field` | regla 6 y regla 8 del contrato; y `field` está instalada y sin usar |
| «Vamos a consultar esta propiedad» + `border-t pt-4` (`Create.tsx:186-193`) | al mismo bloque de vista previa | `Separator` | el `border-t` a mano es la primitiva `separator`, instalada y sin usar |
| `Alert` «Después de esto falta un paso» (`Create.tsx:202-217`) | `Section` «Después del alta falta autorizarnos» | `Section` + `LabelValue` + `CopyButton` | no es una condición ni un error: es el paso siguiente. Un `Alert` le pone urgencia a algo que todavía no pasó y compite con el error de validación real |
| Los cinco `<code>` con cuatro clases | uno solo | `CodeChip` | la definición del pedido «estandarizalos» |
| Fila de botones (`Create.tsx:219-226`) | se queda al pie del formulario | `Button` + `Button variant="ghost"` | un submit va adentro del `<form>` y al lado del último campo. **No** al slot `actions` del encabezado: regla 21 |

### La normalización, sin que parezca un error

Tres decisiones, y las tres son de ubicación:

1. **Deja de ser un mensaje junto a un campo y pasa a ser el eco del formulario entero.** Un bloque
   de sólo lectura después del grupo de radios, con dos pares: «Se guarda como» → `ejemplo.com` y
   «Consultamos esta propiedad» → `sc-domain:ejemplo.com`. Los dos con el mismo `CodeChip`. Un
   mensaje pegado a un campo se lee como una corrección; una ficha de sólo lectura que refleja lo
   que el formulario entendió se lee como una confirmación. Es la misma información en un rol
   distinto.
2. **Tiempo presente y sin promesa.** «Se guarda como», no «Vamos a guardarlo como»: es un hecho
   sobre la forma canónica, no algo que va a pasar (RT-05).
3. **Se anuncia una sola vez.** El `aria-live="polite"` se queda, pero el texto se actualiza al
   quedarse quieto el teclado (~400 ms) o al salir del campo, no por pulsación. Y el bloque no
   aparece ni desaparece: está siempre, con el marcador del `placeholder` cuando el campo está vacío,
   así que nada salta de lugar. Sin animación: es texto que cambia mientras se escribe, y animarlo
   sería llamar la atención sobre algo que no la pide.

Y el `FieldError` queda solo en su slot bajo el input: si lo escrito no es un dominio, no hay nada
que normalizar y el eco muestra el marcador. Un error y una confirmación no comparten renglón.

### Dónde va la explicación

- **Qué es un dominio acá y por qué Search Console** → la `description` de la página, que ya existe y
  ya está en T5 muted con `max-w-prose`. Es una sola oración y es lo primero que se lee. No se
  duplica en ninguna tarjeta.
- **La diferencia entre las dos formas** → dentro del propio grupo de radios, visible y no colapsada.
  Es la decisión que se está tomando; esconderla detrás de un acordeón sería esconder la vista.
- **Por qué hay que autorizarnos y con qué dirección** → `Section` propia al pie, antes del botón,
  con el `client_email` para copiar. Antes del alta y no después: ahorra un viaje, y es lo que ya
  argumenta `views.py:145-148`.

### Componentes compartidos que necesita

- **`CodeChip`** — `{ value: string; copy?: boolean; className?: string }`. Un `<code translate="no">`
  con **una** forma (`bg-muted/60 rounded px-2 py-1 text-sm wrap-anywhere`) y, con `copy`, el
  `CopyButton` al lado en la misma línea. Reemplaza los cinco de V5/V6 y los que hay en V2 y en el
  recorrido guiado.
- **`Section`**, **`LabelValue`** / **`DescriptionList`**, y la familia **`field`** completa
  (`FieldSet` / `FieldGroup` / `Field` / `FieldLabel` / `FieldDescription` / `FieldError`), con
  `FieldError.tsx` y `fieldErrorProps()` conservados como fuente del `aria-invalid` /
  `aria-describedby`.

### Qué no tocar

- Que sea una página. Está argumentado en `views.py:127-133` y el árbol del contrato coincide.
- Las dos opciones **visibles a la vez**, explicadas por *cómo la dio de alta la persona* y no por su
  nombre técnico (`PROPERTY_SHAPES`, `Create.tsx:30-46`). Es el mejor microcopy del producto.
- `normalizeHostname` como **espejo** del servidor, con su docstring que aclara que si discrepan
  manda el servidor (`Create.tsx:48-61`).
- El foco al primer campo con error al volver del servidor (`Create.tsx:71-73`, RT-17).
- `autoComplete="url"`, `inputMode="url"`, `spellCheck={false}`, `autoCapitalize="none"`.
- El `duplicate_id` viajando con el error y su botón «Ver el que ya tenés» (`Create.tsx:121-127`,
  `services.py:114-120`).
- El `client_email` **antes** del alta.

### Reglas en juego

- **RT-05** — «Se guarda como», «Consultamos esta propiedad»; nada de «vamos a indexar», «activar» ni
  «empezar el monitoreo». El dominio nace en `AWAITING_ACCESS` y la vista lo dice.
- **RT-07** — no aparece la verificación por TXT, aunque `Domain.txt_token` exista en el modelo.
- **RT-08** — el error de validación junto al campo, nunca en un toast; y el `Alert` de instrucción
  deja de vestirse de aviso.
- **Contrato §6 reglas 2, 6, 8, 9, 26** — encabezados reales, ningún borde de tarjeta propio, ninguna
  `Card` dentro de otra, ningún `gap` que compita con `<main>`, `motion-reduce:` en la transición de
  color de las opciones.

---

## V6 · Ficha de dominio — `/domains/{id}` — [`frontend/pages/Domains/Show.tsx`](frontend/pages/Domains/Show.tsx)

**Veredicto**: rehacer — **severidad** 4/5
**El problema en una línea**: es un tablero de un objeto y un formulario de configuración a la vez,
y el formulario ocupa el 40 % del alto sin ser nunca la razón por la que se entra.

Aclaración necesaria: lo que se rehace es **la composición de la página**. El razonamiento que está
escrito en este archivo —los tres códigos de error, los dos bolsillos del cupo, por qué el
interruptor guarda solo y los números no, por qué el rótulo del lote cambia con el estado— es lo
mejor del repositorio y no se toca ni una coma.

### Qué pasa hoy

- **1601 px y 18 recuadros medidos.** Contando sólo lo que se lee en el archivo: seis `Card`
  primitivas más **tres `rounded-lg border p-3` dibujados a mano dentro de ellas** —los dos
  `QuotaPocket` (`QuotaMeter.tsx:136`) y el `ChangePreview` (`Show.tsx:957`)—, más dos `border-t pt-4`
  usados como separador (`Show.tsx:408`, `Show.tsx:841`) donde la primitiva `separator` está
  instalada y sin usar. Es `Card` dentro de `Card` en los hechos, que el contrato prohíbe en la
  regla 8, y bordes de tarjeta escritos en una página, que prohíbe en la 6.
- **Una palabra vestida de cifra.** `<CardTitle className="text-2xl">{batch.state_label}</CardTitle>`
  (`Show.tsx:622`) rinde «En cola» a 24 px. El contrato lo cita por nombre en T2. Y no lleva
  `tabular-nums` mientras sus dos hermanas de la misma fila sí (`Show.tsx:564`, `591`): tres tarjetas
  iguales con tres recetas distintas.
- **Los botones de esa fila quedan a distinta altura.** Medido en `v6-domain-show.png`: «Ver los
  sitemaps» a y≈700, «Ver la cobertura» a y≈727. 27 px de desfase porque las tres tarjetas tienen
  contenidos de distinto largo y ninguna usa `h-full` ni `CardFooter`. Es el defecto que §3.3 nombra
  como el que hay que arreglar.
- **Esas tres tarjetas están escritas un paso más chicas que el resto de la página**: `text-xs` como
  cuerpo en las líneas 571, 576, 594, 627 y 632, dentro de una `Card` que ya es `text-sm`. Un bloque
  entero de la ficha se lee como si fuera un pie de página.
- **Prosa a 1130 px.** `<p id="help-notificaciones" className="text-muted-foreground text-sm
  text-pretty">` (`Show.tsx:826`) no lleva `max-w-prose`, así que la línea «Te avisamos en la
  plataforma cuando termina un lote…» cruza toda la columna de contenido. El contrato la cita
  textualmente como el ejemplo de lo que no se hace.
- **El estado del acceso vive sólo en un `title=`.** La ficha rinde `<AccessStateBadge>` sin párrafo
  (`Show.tsx:151`), y `AccessStateBadge` pone su `help` en `title=` (`AccessStateBadge.tsx:94`): un
  atributo inalcanzable por teclado e inexistente en táctil. Es la regla 5 del contrato.
- **La barra del cupo dura 300 ms** (`QuotaMeter.tsx:144`), por encima del techo de 250 ms de §3.12.
- **Se navega a las sub-vistas con tres botones y un enlace subrayado**, cada uno de un tamaño
  distinto, y al llegar a la sub-vista el hilo se corta (ver la sección de coherencia, más abajo).

### En qué orden debería mirarse

1. **Quién es este dominio y en qué estado está, de cuándo** — `h1` + `PageIntro` con el badge, la
   forma de la propiedad, el `property_uri` y la fecha de la última comprobación.
2. **Dónde puedo ir** — la barra de pestañas, en el mismo lugar en las cuatro rutas.
3. **Si no está operativo: qué falta y cómo se arregla** — el banner del estado y la sección de
   autorización, arriba de todo lo demás.
4. **Cuánto cupo queda hoy y qué puedo disparar** — `QuotaMeter` y «Inspeccionar ahora».
5. **Qué hay adentro de cada sub-vista** — una fila de tres cifras.
6. **Cómo cambio la forma en que trabaja** — al pie, y el formulario detrás de un disparador.

### La ficha, ¿secciones o `Tabs`?

**Es una página con secciones, precedida por una barra de pestañas que es navegación entre cuatro
rutas, no `TabsContent`.** Las dos mitades de la frase importan.

**Por qué no son pestañas de contenido.** El árbol del contrato se recorre en orden y se para en el
primer sí. Cobertura (`/domains/{id}/coverage`), Sitemaps y Lotes contestan **sí en el paso 2**:
tienen URL propia, se llega desde afuera —el aviso `DOMAINS_ACCESS_LOST`, un enlace en un lote, la
celda de cobertura de V4 apunta a los sitemaps— y una de ellas mide 5567 px con filtros propios, un
`Sheet` y una `DataTable` con su estado en la URL. Nunca se llega al paso 5, que es el que habilita
`Tabs`. Y si se llegara, el precio sería meter el orden, la página, el filtro y el panel abierto de
Cobertura adentro de un `?tab=`: cuatro estados anidados en un quinto, para que Atrás siga
funcionando.

**Por qué igual hay una barra de pestañas.** La primitiva `tabs` da la **forma** —un grupo de
hermanas excluyentes con una marcada— y `TabsTrigger asChild` con un `<Link>` adentro da la
**mecánica** correcta: son enlaces de verdad, con `aria-current="page"`, que soportan Cmd+clic, el
clic del medio y el teclado, y cuyo estado vive en el path en vez de en un `?tab=`. Es la misma
solución que ya aplica el contrato a los chips de filtro con `ToggleGroup`: la primitiva pone la
forma, el `<Link>` pone la semántica. Nada se anima: cambiar de pestaña es cambiar de página, y es
la navegación que más se repite en el producto.

**Las cuatro pestañas son Resumen · Cobertura · Sitemaps · Lotes.** El contrato lista una quinta,
«Configuración», y ahí me aparto con motivo: una quinta pestaña cuyo estado viviera en `?tab=`
mientras las otras cuatro viven en el path mezcla dos mecanismos en una misma tira; y el propio
árbol, en el paso 3, ya manda el formulario de configuración —tres controles, no bloquea— a una
`Section` o a un `Dialog`, no a una vista hermana. Va como enmienda al final.

**Qué queda en la ficha** (o sea, en la pestaña Resumen):

- La identidad y el estado, con su fecha.
- El banner de estado y el panel de autorización, cuando el dominio no está operativo. Cuando **sí**
  lo está, la instrucción de autorización deja de ser una tarea y pasa a ser documentación: va al pie
  en un `Collapsible` («Cómo nos autorizaste»), que es el paso 6 del árbol para un solo bloque
  secundario. El resultado del último intento —fechado y persistente— se queda adentro.
- El cupo de hoy con sus dos bolsillos, y «Inspeccionar ahora».
- Los tres números de las sub-vistas, en una fila de `MetricCard`.
- El interruptor de notificaciones, como una fila con acción.

**Qué se va de la ficha**: nada de contenido. Cobertura, sitemaps y lotes ya viven en sus rutas; lo
que se va son los **tres botones** que iban ahí, porque ahora la barra de pestañas hace ese trabajo.
Y los dos campos numéricos del cupo se van a un `Dialog` disparado desde la sección que los explica.

**El encabezado del dominio.** El `h1` lo escribe `SiteHeader` y es `truncate`. Con
`title={domain.hostname}` en la ficha eso funciona; con `title={`Cobertura de ${domain.hostname}`}`
en las sub-vistas, el `truncate` corta **el hostname** y deja en pie la palabra genérica: con
`website.patiospoolsanddriveways.test` el encabezado termina diciendo «Cobertura de
website.patiospoolsanddri…». Se invierte el orden —`ejemplo.com · Cobertura`— y el truncado se come
lo genérico, que es lo que la pestaña activa ya dice sin truncar. Debajo, `description` pasa a ser la
bajada real de la pantalla y el metadato que hoy ocupa ese lugar (`Propiedad de dominio ·
sc-domain:ejemplo.com`) baja al `PageIntro`, que es el bloque que el contrato inventó exactamente
para esto.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `description` = `Propiedad de dominio · sc-domain:…` (`Show.tsx:139`) | items del `PageIntro`; la `description` pasa a ser la bajada de la pantalla | `PageIntro` | eso es metadato del objeto, no la bajada de la página |
| Fila suelta badge + «Última comprobación» (`Show.tsx:150-155`) | `PageIntro` | `PageIntro` | el contrato la nombra por su nombre como lo que `PageIntro` reemplaza |
| `StateBanner` (`Show.tsx:237-303`) | se queda, primero del contenido, con sus cuatro ramas | `Alert` | cada estado con su explicación y su salida; un banner único obligaría a adivinar cuál de las tres situaciones es |
| Botones «Ver la cobertura / los sitemaps / el lote» + enlace «Ver todos» | barra de pestañas, presente también en las tres sub-vistas | `tabs` con `TabsTrigger asChild` + `<Link>` | cuatro rutas hermanas del mismo objeto; y así el hilo no se corta al entrar |
| Las tres `Card` de `ChildViews` (`Show.tsx:557-675`) | una fila de tres `MetricCard size="sm" className="h-full"`, **sin acciones** | `MetricCard` | §3.4; sin botones, las tres quedan a la misma altura por construcción y la navegación vive un renglón más arriba |
| `<CardTitle className="text-2xl">{batch.state_label}</CardTitle>` (`Show.tsx:622`) | `value` = `0 de 1.200` (o `—` si todavía no hay cifras) y el estado a `BatchStateBadge` en `CardAction` | `MetricCard` + `StatusBadge` | T2 es una cifra, nunca una palabra de estado |
| `text-xs` como cuerpo en esas tarjetas (571, 576, 594, 627, 632) | T5, heredado de la `Card` | — | T6 es metadato; un bloque entero en T6 se lee como pie de página |
| Los dos campos numéricos + `ChangePreview` (`Show.tsx:841-920`) | `Dialog` «Ajustar el cupo diario», disparado desde el `CardAction` de la sección de cupo | `dialog` (instalar) | árbol paso 3: formulario de 2 campos, una acción primaria, bloquea el flujo. Y se edita al lado de la cifra que cambia |
| `ChangePreview` con su `rounded-lg border p-3` (`Show.tsx:957`) | adentro del `Dialog`, sin borde propio | — | es la consecuencia antes de confirmar: ahí es donde se la lee, y ahí no necesita caja |
| Interruptor de notificaciones (`Show.tsx:802-839`) | `Item` en una `Section` «Este dominio» | `item` (instalar) | rótulo en T4, ayuda en T5 muted, control a la derecha: la anatomía exacta de `Item` |
| `help-notificaciones` sin `max-w-prose` (`Show.tsx:826`) | con `max-w-prose` | — | el contrato cita esta línea como el ejemplo del problema |
| `QuotaPocket` × 2 con `rounded-lg border p-3` (`QuotaMeter.tsx:136`) | dos columnas de igual ancho separadas por un `Separator`, sin borde | `Separator` | son las dos mitades del mismo cupo dentro de una `Card`: §3.3 |
| `border-t pt-4` en `AccessPanel` (408) y `DomainSettings` (841) | `Separator` | `separator` | instalada y sin usar; §5 nombra este hueco |
| `AccessPanel` cuando el dominio **sí** está operativo (`Show.tsx:216-222`) | `Collapsible` «Cómo nos autorizaste», al pie | `collapsible` (instalar) | operativo, es documentación; sin acceso, es la vista entera y sigue arriba, como ya hace bien |
| `title=` del badge (`AccessStateBadge.tsx:94`) | `Tooltip` | `tooltip` | `title` no se alcanza con el teclado ni existe en táctil (regla 5) |

**No hay `DangerZone` en esta vista, y es correcto**: no existe ruta de borrado de dominio
(`PUBLISHED_ROUTES` tiene `domain.show`, `domain.check` y `domain.sync`, nada más). Inventar una
sección destructiva sería inventar una acción. Y `domain.sync` **no es de esta pantalla**: vive en
Sitemaps (`Sitemaps/Index.tsx:264`), que es donde están las URLs que sincroniza. La ficha tiene dos
acciones sobre Google: comprobar acceso e inspeccionar ahora.

### Cómo se sostiene la identidad del dominio en las cuatro sub-vistas

Hoy el hilo se corta de tres maneras distintas, y las tres son medibles:

- **La navegación lateral es distinta en cada vista.** Cobertura ofrece «Sitemaps» + «Consultar
  ahora» (`Coverage/Index.tsx:374-386`); Sitemaps ofrece «Ver la cobertura» + «Sincronizar»
  (`Sitemaps/Index.tsx:253-262`); Lotes ofrece «Ver el dominio» + «Ver la cobertura»
  (`Batches/Index.tsx:195-203`). Tres juegos, ninguno completo: **desde Cobertura no hay forma de
  volver a la ficha ni de ir a los lotes**.
- **El estado del dominio desaparece.** Ninguna de las tres sub-vistas rinde `AccessStateBadge`.
  Cobertura lo dice en prosa dentro de un `Alert`, pero **sólo cuando `canOperate` es verdadero**
  (`Coverage/Index.tsx:409`); Sitemaps igual (`:290`); Lotes recibe `access_state` en las props y no
  lo muestra nunca. O sea: con la credencial de la cuenta caída, las tres sub-vistas muestran datos
  viejos **sin ninguna marca de que lo son**. R-F se cumple hoy en V4 y en V6 y **no se cumple** en
  V7, V8 ni V9.
- **El encabezado trunca justo la identidad**, como quedó dicho arriba.

Tres piezas, en este orden, idénticas en las cuatro rutas y ubicadas siempre en el mismo lugar:

1. **El `h1` empieza por el hostname.** `ejemplo.com · Cobertura`, `ejemplo.com · Sitemaps`,
   `ejemplo.com · Lotes`, `ejemplo.com` a secas en la ficha. Es un cambio de texto —permitido en el
   armazón— y hace que lo primero que anuncia un lector de pantalla en las cuatro páginas sea el
   objeto, no el sustantivo genérico. `AppLayout` compone `<Head>` con el mismo texto, así que la
   pestaña del navegador también queda ordenada por dominio, que es como se agrupan cuando hay
   varias abiertas.
2. **`DomainIdentity`**, un preset de `PageIntro`: `StatusBadge` del acceso + la forma de la
   propiedad + el `property_uri` en `CodeChip` + «Comprobado <fecha>». Una línea, la misma en las
   cuatro. Es lo que hace que R-F valga en toda la rama: con la credencial caída, las cuatro páginas
   muestran el mismo badge punteado y la misma fecha vieja, sin repetir el aviso de la cuenta.
3. **`DomainTabs`**, inmediatamente debajo. Cuatro enlaces, `aria-current="page"` en el activo, sin
   animación. Es la respuesta a «pierdo el hilo»: el usuario no vuelve a la ficha para saltar a otra
   sub-vista, salta directo, y en todo momento ve cuáles son las otras tres.

Las tres viven en el área de contenido, ninguna toca el armazón, `breadcrumb` sigue descartada y el
slot `actions` del encabezado queda libre para lo único que le corresponde: la acción primaria de esa
vista en una sola línea («Inspeccionar ahora» en Cobertura, «Sincronizar» en Sitemaps, nada en Lotes
y nada en la ficha). Hoy ese slot lleva dos botones donde uno es navegación; con la barra de
pestañas, la navegación sale de ahí y el encabezado deja de competir consigo mismo.

### Componentes compartidos que necesita

- **`DomainTabs`** — `{ domainId: string; current: 'summary' | 'coverage' | 'sitemaps' | 'batches' }`.
  `Tabs` con `TabsTrigger asChild` + `<Link>`, sin `TabsContent`. Sin cifras en los rótulos: la
  navegación es navegación, los números están en la fila de `MetricCard` de la ficha.
- **`DomainIdentity`** — `{ domain: DomainProps; canOperate: boolean }`. Compone `PageIntro` con
  `AccessStateBadge`, la forma de propiedad, el `property_uri` en `CodeChip` y `DataTimestamp`. Es la
  pieza que hace cumplir R-F en V7, V8 y V9, donde hoy no se cumple.
- **`DomainQuotaDialog`** — `{ domain, cycle, googleDailyLimit }`. El formulario de dos campos con su
  `ChangePreview` adentro, sobre `dialog` + `field`. Se dispara desde el `CardAction` de la sección
  de cupo. Conserva íntegras `validate()` y `toInteger()`, el foco al primer campo con error y el
  guardado con botón explícito.
- **`MetricCard`**, **`PageIntro`**, **`Section`**, **`Item`**, **`LabelValue`**, **`CodeChip`**,
  **`CoverageFigure`**, **`StatusBadge`** — todos ya definidos por el contrato o por V4/V5.
- A instalar para esta vista: `dialog`, `collapsible`, `item`, `spinner`.

### Qué no tocar

- El **mapa cerrado `ACCESS_ERRORS`** ramificado por código y no por texto (`Show.tsx:92-107`), con
  la acción distinta por código. Es RT-08 hecho bien.
- Que `AccessPanel` **suba** cuando el dominio no está operativo y baje cuando sí
  (`Show.tsx:167-173` contra `216-222`). Reordenar la vista alrededor del estado es lo que pide el
  checklist, y ya está hecho.
- El **orden del DOM**: el banner antes que el cupo, con su docstring (`Show.tsx:157-162`).
- Los **dos bolsillos** de `QuotaMeter` y su barra propia. El contrato la exime explícitamente junto a
  `BatchProgress`: son las dos únicas barras dibujadas a mano que sobreviven.
- `ChangePreview` **sin región viva** (`Show.tsx:952-956`): cambia con cada tecla y anunciarlo sería
  ruido continuo.
- El interruptor con **estado propio y reversión al fallar** (`Show.tsx:706-763`), y el reparto
  «interruptor guarda solo / números guardan con botón» con su docstring (`Show.tsx:678-686`).
- `useBatchPolling` como **único** mecanismo de actualización (RT-13).
- El campo `from: 'show'` en vez de leer el `Referer` (`views.py:245-252`).
- El rótulo del lote que cambia según haya uno en marcha (`Show.tsx:615-620`).
- La forma de la propiedad **no editable**, con «Dar de alta la otra forma» como acción real
  (`Show.tsx:331-340`).

### Reglas en juego

- **RT-01 / RT-02** — el estado y su fecha nunca se separan: van juntos en `PageIntro`. El
  `TimezoneFootnote` se queda al pie.
- **RT-03** — la `MetricCard` de cobertura muestra `92 de 120`, nunca un porcentaje suelto; el
  denominador va en `CardAction`.
- **RT-04** — el estado del lote pasa a `BatchStateBadge` (icono + palabra) en vez de vivir como
  palabra a 24 px.
- **RT-05** — «Consultamos qué sabe Google de cada una: no le pedimos que las rastree» se queda tal
  cual en la confirmación de `InspectNowAction`.
- **RT-07** — nada de verificación por TXT, nada de planes ni de límites de plan; y ninguna acción
  deshabilitada con explicación: se reemplaza por la que resuelve.
- **RT-12** — sólo el control tocado queda en espera; el resto de la ficha sigue usable.
- **RT-18 / R-F** — con `can_operate` en falso, `operational` es falso aunque el dominio esté en
  `OPERATIONAL`, y `DomainIdentity` lleva esa verdad a las tres sub-vistas donde hoy no llega.
- **Contrato §6 reglas 5, 6, 7, 8, 10, 11, 21, 24, 27**.

### Movimiento

- El `Dialog` del cupo entra en 200 ms desde `scale(0.97)` + `opacity: 0`, con
  `cubic-bezier(0.23, 1, 0.32, 1)`, **centrado** —un diálogo no está anclado a nada, así que conserva
  `transform-origin: center`— y sale más rápido de lo que entra.
- El `Collapsible` de la autorización anima `height` con `--radix-collapsible-content-height` en
  200 ms; es de las pocas cosas de esta pantalla que se abren y se cierran a propósito.
- La barra de `QuotaMeter` baja de 300 ms a 200 ms (`QuotaMeter.tsx:144`).
- Cambiar de pestaña **no se anima**: es una navegación, y es la más repetida del producto.
- Los botones en vuelo llevan `Spinner` + gerundio + `…` («Comprobando…», «Inspeccionando…»,
  «Guardando…»), quedan habilitados hasta que arranca la petición, llevan `aria-busy` y **no muestran
  nada por debajo de 300 ms**.
- Todo lo presionable lleva `active:scale-[0.97]` a 120 ms, y todo lo que se mueve lleva su
  `motion-reduce:`.

---

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

**1. `CardTitle` rinde un `<div>`, y ahí está el origen de «cero `h2` y `h3`».**
`ui/card.tsx:36` devuelve un `<div>` con las clases de T3. O sea que una vista puede tener títulos
visibles y perfectos y medir cero encabezados —es literalmente lo que pasa en V5 y en V12—, y
arreglarlo página por página son catorce archivos. **Enmienda al contrato §3.3**: `CardTitle` acepta
`asChild` (o `as`) y se le pasa `h2` en toda `Card`, `h3` cuando la `Card` está dentro de una
`Section` que ya tiene su `h2`. **Y una excepción declarada para `MetricCard`**, porque §1 dice «T2
nunca es una etiqueta de encabezado» mientras §3.4 pone el valor en `CardTitle`: en `MetricCard` el
encabezado es la **etiqueta**, así que el `h3` va en `CardDescription` (`asChild`) y `CardTitle` se
queda como `div`. Sin esta aclaración, cada agente resuelve la contradicción por su cuenta.

**2. La regla de las pestañas: forma de la primitiva, semántica del `<Link>`.**
El paso 2 del árbol («¿tiene URL propia?» → página) y la fila «Tabs» de la tabla se contradicen para
la ficha de dominio. La resolución que propongo vale para todo el producto y hay que subirla al
contrato: **cuando dos a cinco vistas hermanas del mismo objeto ya son rutas, la barra de pestañas es
navegación** —`TabsTrigger asChild` + `<Link>` + `aria-current="page"`, sin `TabsContent`— y el
estado vive en el path, no en `?tab=`. `?tab=` queda sólo para hermanas que **no** tienen ruta. Es la
misma jugada que el contrato ya hace con `ToggleGroup` para los chips de filtro: la primitiva pone la
forma, el enlace pone la semántica. **Y la fila de la tabla se corrige a «Ficha de dominio: Resumen ·
Cobertura · Sitemaps · Lotes»**, sin «Configuración»: un formulario de tres controles que no bloquea
lo manda el paso 3 a una `Section` o a un `Dialog`, no a una vista hermana, y una quinta pestaña en
`?tab=` junto a cuatro en el path mezcla dos mecanismos en una misma tira.

**3. `tone` e `icon` son props independientes de `StatusBadge`, y eso resuelve R-F sin inventar nada.**
La tabla de §3.7 describe `unknown` con `Clock` porque ése es su caso canónico (RT-03), no porque el
icono esté fijado. Un estado real que **hoy no podemos confirmar** se rinde con `tone="unknown"` y el
icono y la palabra del estado: punteado, muted, «Operativo». Lo contrario —reescribir la etiqueta,
que es lo que hace hoy `AccessStateBadge`— desincroniza el filtro («Operativo» devuelve filas que
dicen «En espera») y le atribuye al objeto un problema de la cuenta. **Regla nueva, verificable: R-F
se cumple bajando el tono, nunca cambiando la palabra.**

**4. Un texto que depende de un estado no se repite por fila.**
El párrafo de ayuda de la celda «Acceso» de V4 es del estado, no del renglón, y con la credencial
caída es la misma frase en las trescientas filas. Vale para V9 (estados de lote), V12 (agente
declarado) y V8. **Regla: en una tabla, la explicación de un estado vive en el `Tooltip` de su badge
y, una sola vez, en el `EmptyState` o en el pie. Nunca por fila.**

**5. Una misma pregunta se contesta en un solo lugar.**
V5 contesta «¿qué entendiste de lo que escribí?» dos veces y a 500 px de distancia, y V6 tiene la
`description` diciendo metadato mientras el metadato flota suelto abajo. **Regla: el eco de un
formulario es un bloque de sólo lectura al final, no un mensaje pegado a cada campo** —así se lee
como confirmación y no como corrección—, **y se anuncia al quedarse quieto el teclado, no por
pulsación**. Aplica a V2 (configuración), V11 (claves) y al recorrido guiado.

**6. `CodeChip` falta y se nota.** Cinco `<code>` con cuatro juegos de clases sólo entre `Create.tsx`
y `Show.tsx`. Es el caso más chico y más repetido de «las de un lado son distintas de las del otro»,
y toca V2, V3 y V11 además del grupo B. Va al contrato §3: `{ value: string; copy?: boolean }`, un
`<code translate="no">` con `wrap-anywhere` y el `CopyButton` al lado en la misma línea.

**7. Los formularios necesitan un ancho declarado.** V5 usa `max-w-2xl`, V6 pone el mismo tipo de
formulario a 1130 px, y el contrato sólo legisla `max-w-prose` para texto corrido. **Enmienda a §2:
un formulario de campos cortos vive en `max-w-2xl`; a ancho completo sólo van las tablas, las filas
de `MetricCard` y la prosa —que va en `max-w-prose`.**

**8. El umbral de `toggle-group` es de ancho, no de conteo.** §5 dice «chips de filtro con ≤5
opciones», pero el filtro canónico de este producto es «Todos» más los cinco estados: seis, y es el
mismo patrón en V4, V7 y V9. Medido, ocupa 630 px de los 1130 disponibles. **Enmienda: `ToggleGroup`
hasta seis opciones de una palabra que entren en una línea a 1280 px; `Select` a partir de ahí.** Y
las opciones son `ToggleGroupItem asChild` + `<Link>` para no perder la URL, que es lo que hace que
`/domains?access_state=ACCESS_LOST` funcione como destino de un aviso.

**9. La identidad del objeto se sostiene con tres piezas, no con un breadcrumb.** El `h1` que
**empieza** por el nombre del objeto (para que el `truncate` se coma lo genérico), un `PageIntro`
idéntico en todas las rutas hijas y una barra de pestañas en el mismo lugar. La ficha de lote (V10)
tiene el mismo problema —cuelga de un dominio y no lo dice— y se resuelve igual.

**10. Lo que un agente mide y lo que un agente lee no siempre coinciden.** V4 aparecía en el brief
como una lista sin `table`, y usa `DataTable` desde `Index.tsx:7`. Las «nueve tarjetas» eran seis
chips redondeados, el contenedor de la tabla y dos `Alert`. Antes de proponer un rediseño conviene
comprobar en el archivo el defecto que la medición sugiere: la mitad del trabajo de V4 ya estaba
hecho, y proponerlo de nuevo habría sido tirarlo.
