# UX Checklist: interfaz de index-relay

**Feature**: 001-gsc-sitemap-coverage
**Fecha**: 2026-08-17
**Alcance**: las doce páginas de Inertia del *Inventario de vistas* de [plan.md](./plan.md)
**Insumos**: [spec.md](./spec.md), [data-model.md](./data-model.md),
[contracts/openapi.yaml](./contracts/openapi.yaml),
[constitution.md](../../.specify/memory/constitution.md) v1.1.0

---

## 0. Cómo usar este documento

Cada vista trae siete bloques: propósito, elementos obligatorios (casillas verificables), estados
a cubrir, jerarquía, microcopy listo para copiar, accesibilidad y trampas del dominio. Una vista
está terminada cuando todas sus casillas se pueden marcar mirando la pantalla construida, sin
leer el código.

Las dos secciones transversales van **antes** de las vistas a propósito: cada vista las referencia
por identificador (`RT-xx` para las reglas, `C-xx` para los componentes) en vez de repetirlas doce
veces. Si una vista contradice una regla transversal, gana la regla.

**El usuario objetivo no sabe qué es Google Cloud.** Ese dato gobierna cada decisión de acá abajo:
nada se explica por analogía con la consola de Google, todo paso dice su objetivo antes que su
camino, y ningún error deja al usuario sin una acción concreta.

**Las cinco restricciones que más cuestan si se rompen** (principios I, III y VI de la
constitución):

| # | Restricción | Dónde se rompe con más facilidad |
|---|---|---|
| R-A | Ningún estado de indexación sin su fecha de obtención | Tablas de cobertura, tarjetas de resumen, exportación, correos |
| R-B | `UNKNOWN` es "todavía no consultamos", no "no indexada" | Gráficos de torta, porcentajes, filtros "no indexadas" |
| R-C | Un lote interrumpido por cuota es `PARTIAL`, jamás `COMPLETED` | Iconografía de la lista de lotes, barra de progreso al 100 % |
| R-D | Nunca se promete indexación ni velocidad de rastreo | Estados vacíos, textos de éxito, pantalla final del recorrido |
| R-E | El material de la clave no se muestra nunca; sólo huella y dirección | Ficha de credencial, props de Inertia, registros de error |
| R-F | Un dominio no se presenta como operativo si la credencial de la cuenta dejó de servir | Listado y ficha de dominios, acciones que llaman a Google (FR-064, RT-18) |

---

## 1. Reglas transversales

Patrones que toda vista cumple. Son verificables una por una.

### RT-01 · Fechas: relativa arriba, absoluta siempre disponible

- Toda fecha se renderiza con `C-08 DataTimestamp`, que emite `<time dateTime="{ISO 8601}">`.
- Menos de 7 días: texto relativo con `Intl.RelativeTimeFormat('es-AR')` — “hace 3 horas”,
  “ayer”. Desde 7 días: fecha absoluta — “14 ago 2026, 09:12”.
- El valor absoluto está **siempre** disponible en el atributo `title` y como texto accesible, aunque
  se muestre el relativo. Un dato de cobertura nunca se muestra sólo como “hace un rato”.
- La zona de presentación **no se toma del navegador**: viene de `display_timezone` en
  `GET /account` (FR-065) y se aplica al formatear con `Intl.DateTimeFormat`. Se muestra al pie de
  toda vista con datos fechados: “Todas las horas en America/Argentina/Buenos_Aires”.
- Es la misma zona con la que se corta el día del presupuesto de cuota, así que “el cupo de hoy”
  significa lo mismo en la pantalla que en la base.

### RT-02 · Ningún estado de indexación sin fecha

- `C-05 CoverageStateBadge` recibe el par `{state, fetchedAt}` y **no puede renderizarse sin él**.
- Si `state !== 'UNKNOWN'` y `fetchedAt` viene nulo, el componente muestra “Sin fecha de obtención”
  en tratamiento de error y registra el defecto: no se oculta el dato ni se inventa una fecha.
- En tablas, la fecha va en su **propia columna**, no en un tooltip: tiene que sobrevivir a la
  exportación y a la captura de pantalla.

### RT-03 · `UNKNOWN` tiene familia visual propia

- Tres familias, nunca dos: **con dato positivo** (`INDEXED`), **con dato negativo** (los otros
  ocho estados), **sin dato** (`UNKNOWN`).
- `UNKNOWN` se dibuja con borde punteado, fondo neutro y un icono de reloj. Prohibido el rojo, el
  ámbar y cualquier tinte que lo agrupe con los estados negativos; prohibido el verde.
- `UNKNOWN` **queda fuera** de todo porcentaje de indexación y de toda barra apilada de estados.
  Se cuenta aparte, con su propia etiqueta.
- Todo porcentaje lleva su denominador visible: “62 % de las 1.240 URLs con dato”, nunca “62 %”.

### RT-04 · Ningún estado se comunica sólo por color

- Cada estado es **icono + palabra**, en ese orden. Verificación: en escala de grises, los cinco
  estados de acceso, los diez de cobertura y los cinco de lote siguen distinguiéndose.
- En tablas, el estado es texto en su celda; el color es refuerzo, no portador.

### RT-05 · Vocabulario sin promesas

- Verbos permitidos: *sincronizar*, *enviar*, *consultar*, *inspeccionar*, *monitorear*,
  *informar*, *registrar*.
- Verbos prohibidos en toda la interfaz: *indexar tu sitio*, *forzar*, *acelerar*, *garantizar*,
  *posicionar*, *asegurar la indexación*. Prohibido también “Google ya lo tiene”, “listo, quedó
  indexado” y cualquier estimación de cuándo Google va a rastrear.
- Cuando la plataforma hizo algo, el texto nombra **lo que hicimos**: “Enviamos el sitemap” y, si
  hace falta aclarar, “Enviarlo le avisa a Google que existe; no decide si lo rastrea ni cuándo”.
- Verificable contra el test T131.

### RT-06 · Secretos

- Las props de Inertia de cualquier página pueden contener `client_email`, `key_fingerprint` y
  `private_key_id`. **Nunca** el contenido de la clave, ni parcial, ni ofuscado.
- El valor completo de una clave de API vive únicamente en el estado de React de la vista que la
  creó, se pierde al navegar y nunca se vuelve a pedir al servidor.
- Ningún campo de credencial es `type="text"` con el secreto adentro “por comodidad”.

### RT-07 · Lo apagado no existe en pantalla

- Prohibido mencionar planes, precios, límites de plan y verificación por registro TXT: ni texto,
  ni icono, ni entrada de menú, ni pestaña vacía, ni botón deshabilitado con globo explicativo, ni
  “Próximamente”.
- Prohibido dejar rutas alcanzables que muestren una capacidad apagada aunque sea a medias.
- Verificable contra el test T129.

### RT-08 · Errores del servidor

- Los errores estructurados llegan como `{error: {code, message, details}}`. Cada vista mantiene un
  **mapa cerrado de códigos** a `{título, explicación, acción}` con `C-13 ServerErrorNotice`.
- Un código no mapeado muestra el `message` que vino del servidor y una acción neutra (“Volvé a
  intentar” / “Volver al listado”). Nunca inventa una causa ni culpa a la credencial del usuario.
- Los errores de validación de formulario aparecen **junto al campo**, no en un toast, con
  `aria-describedby` y `aria-invalid`.

### RT-09 · Tablas largas

- Paginación del **servidor** con el parámetro `page`; 50 filas por página; el total (`count`)
  siempre visible: “1.240 URLs · página 3 de 25”.
- Filtros, página y orden viajan en la querystring: la vista es enlazable, el botón Atrás del
  navegador funciona y recargar no pierde el filtro.
- Encabezado fijo al hacer scroll, `aria-sort` en las columnas ordenables, la tabla dentro de un
  contenedor con `overflow-x: auto` — la página nunca hace scroll horizontal.
- Las URLs largas se cortan con `overflow-wrap: anywhere` y conservan su texto completo
  seleccionable; nunca se recortan con puntos suspensivos sin dejar forma de ver la dirección
  entera.

### RT-10 · Confirmación de acciones destructivas

Aplica a: revocar una clave de API, reemplazar la credencial, cerrar sesiones, revocar el acceso
de un dominio.

- Diálogo (`C-14 ConfirmDestructive`) que **nombra el objeto** y dice la consecuencia en tiempo
  futuro: “Cualquier pipeline que use esta clave va a empezar a recibir un error.”
- El botón dice el verbo — “Revocar la clave” —, nunca “Aceptar”.
- Foco inicial en “Cancelar”; `Escape` cancela; en acciones irreversibles, hacer clic afuera **no**
  cierra el diálogo.
- Si la acción es irreversible, el diálogo lo dice con esas palabras: “Esta acción no se puede
  deshacer.”

### RT-11 · Dónde va el feedback

| Tipo de acción | Dónde aparece el resultado |
|---|---|
| Validación de un campo | Debajo del campo, al perder el foco o al enviar |
| Comprobación de un objeto (credencial, acceso, paso) | En el bloque del objeto, junto al botón que la disparó, y persiste |
| Acción que redirige (alta de dominio, alta de sitemap) | Mensaje efímero en la vista de destino, más el objeto ya visible |
| Acción que no cambia la pantalla (copiar) | Confirmación breve junto al control, anunciada a lector de pantalla |

- Ningún toast es el único portador de información que no se puede recuperar. La clave de API
  recién creada **no** va en un toast.
- Todo resultado de comprobación queda escrito en la pantalla con su fecha; no desaparece solo.

### RT-12 · Cargando

- Carga inicial de una lista: esqueleto con la forma real del contenido (`Skeleton`), mismo alto de
  fila, para que no salte el diseño.
- Acción sobre un objeto: el botón que la disparó cambia su texto (“Comprobando…”), toma
  `aria-busy="true"` y se deshabilita **sólo él**. El resto de la pantalla sigue usable.
- Nunca una capa que bloquee toda la pantalla; nunca un spinner sin texto.
- Toda operación que puede tardar más de 10 segundos se resuelve como lote y la pantalla lo dice
  (RT-13).

### RT-13 · Trabajo asíncrono

- Toda acción que devuelve `202` (sincronizar, encolar inspección) muestra el lote creado y un
  enlace a su detalle: “Se encoló el lote #a3f2. Ver su progreso.”
- La pantalla **no finge que terminó**: el estado inicial es “En cola”, no “Listo”.
- Mientras un lote está en `QUEUED` o `RUNNING`, la vista consulta su estado **cada 5 segundos** y
  corta al llegar a un estado terminal o cuando la pestaña deja de estar visible
  (`document.visibilityState !== 'visible'`).
- Es el **único** mecanismo de actualización del producto (FR-063): vive en un solo hook compartido
  y ninguna vista escribe el suyo. Verificable: no hay ningún `setInterval` fuera de ese hook.

### RT-14 · Movimiento

- Transiciones de 150 a 300 ms sobre `transform` y `opacity`; nada de animar `width`, `height` ni
  posición.
- `prefers-reduced-motion: reduce` desactiva todo lo que no sea un cambio de opacidad.
- Prohibida cualquier animación que sugiera avance inexistente: nada de barras indeterminadas
  reptando en un lote que no arrancó.

### RT-15 · Foco y teclado

- `:focus-visible` visible en todo control interactivo, con contraste suficiente contra su fondo;
  nunca `outline: none` sin reemplazo.
- Orden de tabulación igual al orden visual. Todo lo que se puede hacer con el mouse se puede hacer
  con el teclado, incluida la zona de arrastrar y soltar del archivo de clave.
- Los diálogos atrapan el foco y lo devuelven al control que los abrió al cerrarse.
- Los formularios se envían con Enter desde cualquier campo de texto.

### RT-16 · Idioma y formato

- Español rioplatense, voseo, segunda persona: “Pegá”, “Subí”, “Agregá”, “Volvé a comprobar”.
- Números con `Intl.NumberFormat('es-AR')`: 2.000, no 2,000.
- Comillas tipográficas “…”, puntos suspensivos con el carácter …, espacio duro entre número y
  unidad (5 000 URLs → “5.000 URLs” con espacio duro antes de la unidad cuando corresponda).

### RT-17 · Después de una acción (Inertia)

- Éxito: redirección al recurso afectado con mensaje efímero, y el objeto ya visible en su nuevo
  estado.
- Error de validación: se vuelve al formulario **con los valores conservados**, el foco va al primer
  campo con error y el resumen de errores está enlazado a cada campo.
- Nunca se pierde lo que el usuario escribió, especialmente el identificador de proyecto y la
  dirección del sitemap.

### RT-18 · El estado de la cuenta manda sobre el estado del dominio

- `GET /account` (FR-064) devuelve `credential_status`, `can_operate`, `domains_with_lost_access`,
  `display_timezone` y una lista de `notices`. `C-01 AppLayout` los recibe en toda vista y los
  rinde con `C-18 AccountNotice`.
- **Con `can_operate` en falso, ningún dominio se presenta como operativo**, aunque su
  `access_state` siga siendo `OPERATIONAL`. El estado del dominio describe su propiedad en Search
  Console; el de la cuenta, si tenemos con qué consultarla. Los dos se miran juntos antes de decir
  que algo funciona.
- Mientras `can_operate` sea falso, las acciones que llaman a Google —comprobar acceso,
  sincronizar, inspeccionar— no se ofrecen, y en su lugar aparece la acción que resuelve el aviso.
  No se ofrecen deshabilitadas: se reemplazan.
- Los tres códigos de aviso tienen texto y `action_path` propios:

| `code` | Mensaje | Acción |
|---|---|---|
| `CREDENTIAL_INVALID` | “La clave de tu cuenta de servicio dejó de funcionar. Mientras tanto no podemos consultar ninguno de tus dominios; el historial se conserva.” | “Revisar la conexión” → `/settings` |
| `CREDENTIAL_REVOKED` | “La clave de tu cuenta de servicio fue revocada desde Google Cloud. Cargá una nueva para retomar el monitoreo.” | “Cargar una clave nueva” → `/settings` |
| `DOMAINS_ACCESS_LOST` | “Dejamos de poder leer 2 de tus propiedades. El monitoreo de esos dominios está detenido.” | “Ver los dominios” → `/domains?access_state=ACCESS_LOST` |

- El aviso nunca es decorativo ni descartable mientras la causa siga vigente: desaparece cuando se
  resuelve, no cuando el usuario lo cierra.

---

## 2. Componentes compartidos

### 2.1 De shadcn/ui (se instalan, no se escriben)

`Button`, `Input`, `Label`, `Select`, `Switch`, `Checkbox`, `Badge`, `Card`, `Table`, `Dialog`,
`AlertDialog`, `Alert`, `Progress`, `Skeleton`, `Sonner` (mensajes efímeros), `DropdownMenu`,
`Tooltip`, `Separator`, `Form`, `Sheet` (panel lateral del historial de una URL) — 20 en total. Se
usan sin modificar su comportamiento de foco ni de teclado.

### 2.2 A medida (18)

| ID | Componente | Qué recibe | Estados que soporta | Usado en |
|---|---|---|---|---|
| C-01 | `AppLayout` | `{account, currentPath, accountStatus}` — `accountStatus` es la respuesta de `GET /account` | normal · con aviso de cuenta vigente | Todas menos V1 |
| C-02 | `GuideStep` | `{id, titulo, objetivo, rutaEnGoogle[], ejemplo, estado, mensajeFalta, accionUrl, onComprobar}` | pendiente · comprobando · cumplido · falló | V2, V3 |
| C-03 | `KeyFileExample` | `{camposResaltados[]}` | estático | V2, V3 |
| C-04 | `CredentialErrorPanel` | `{errorCode, message, actionUrl, projectId, clientEmail}` | los 5 códigos + código desconocido | V2, V3 |
| C-05 | `CoverageStateBadge` | `{state, fetchedAt}` — el par es obligatorio | 10 estados en 3 familias (RT-03) | V7, V10 |
| C-06 | `AccessStateBadge` | `{state, checkedAt, error}` | 5 estados en 2 familias: acción pendiente / falla | V4, V6 |
| C-07 | `BatchStateBadge` | `{state, reason}` | 5 estados; `PARTIAL` con icono e ilustración propios | V9, V10 |
| C-08 | `DataTimestamp` | `{value, prefijo?, forzarAbsoluta?}` | con valor · sin valor (“Nunca”) | Todas las vistas con datos |
| C-09 | `CopyableValue` | `{value, label, mono?}` | listo · copiado (anuncio accesible) | V2, V6, V11 |
| C-10 | `QuotaMeter` | `{limitTotal, manualReserve, usedAutomatic, usedManual, date}` | con saldo · automático agotado · todo agotado | V6, V10 |
| C-11 | `DataTable` | `{columns, rows, count, page, filtros, orden}` | cargando · vacía · con datos · error | V4, V7, V8, V9, V11, V12 |
| C-12 | `EmptyState` | `{titulo, explicacion, accionPrimaria, accionSecundaria?}` | único | V4, V7, V8, V9, V11 |
| C-13 | `ServerErrorNotice` | `{code, message, details, accion}` | mapeado · no mapeado (RT-08) | Todas |
| C-14 | `ConfirmDestructive` | `{titulo, consecuencia, textoBoton, irreversible}` | idle · ejecutando | V2, V6, V11, V12 |
| C-15 | `CoverageSummary` | `{summary, total, fullCycleEstimateDays, ventanaDeFechas}` | sin dato alguno · con dato parcial · con dato completo | V7 |
| C-16 | `StepProgress` | `{steps[], currentStep, completedSteps[]}` | 7 pasos, cada uno pendiente/actual/cumplido | V3 |
| C-17 | `BatchProgress` | `{total, processed, failed, state}` | en cola · en curso · terminal | V9, V10 |
| C-18 | `AccountNotice` | `{notices[], canOperate, domainsWithLostAccess}` — cada aviso trae `{code, message, actionPath}` | sin avisos (no ocupa lugar) · un aviso · varios apilados | Todas menos V1, dentro de `C-01` |

**Contratos que no se negocian:**

- `C-05` **no acepta** `fetchedAt` nulo salvo con `state === 'UNKNOWN'` (RT-02).
- `C-04` mantiene un mapa cerrado de los cinco códigos. Ante un código desconocido muestra el
  mensaje del servidor y **no** marca la credencial como inválida.
- `C-07` usa un icono distinto para `COMPLETED` y para `PARTIAL`; jamás la misma tilde con otro
  color.
- `C-10` dibuja **dos** cupos (automático y reserva manual). Nunca uno solo: el trabajo automático
  no puede tocar la reserva manual y eso tiene que verse.
- `C-15` separa físicamente el bloque “con dato” del bloque “sin consultar todavía”.
- `C-01` **no renderiza su contenido sin `accountStatus`**: de ahí salen la zona de presentación de
  toda fecha (RT-01) y `can_operate`, que decide qué acciones se ofrecen (RT-18).
- `C-18` se rinde siempre en el mismo lugar —arriba del contenido, dentro del layout, con el mismo
  ancho que la vista— y no tiene botón de cerrar mientras la causa siga vigente.

---

## 3. Las trece vistas

### V0 · `/` — `Dashboard`

**Propósito**: contestar en cinco segundos las dos preguntas con las que alguien abre la
plataforma: **¿hay algo roto?** y **¿cuánto sabemos hoy de mis sitios?**. Con la respuesta a la
primera arriba de todo, porque una cobertura preciosa sobre un dominio que perdió el acceso hace
tres días es una foto vieja presentada como si fuera de hoy.

**La raíz no puede ser una redirección al listado.** Un listado de dominios es un inventario, no
un estado: obliga a entrar en cada fila para saber si algo anda mal, y con seis dominios eso son
seis viajes para responder una pregunta que se contesta con una frase.

**Elementos obligatorios**

- [ ] **Lo que necesita acción, primero y sólo si existe**: dominios sin acceso, credencial que
      dejó de servir, lotes que fallaron. Cada uno con su enlace a donde se resuelve
- [ ] Cuando no hay nada roto, se dice —«Todo en orden»— con la fecha de la última comprobación.
      No se deja el hueco ni se rellena con una tarjeta vacía
- [ ] **Línea de honestidad de la cuenta**: cuántas URLs tienen dato sobre el total, en cuántos
      dominios, y desde cuándo (R-A, R-B)
- [ ] Tarjetas de resumen (`section-cards` del bloque): dominios, cobertura con su denominador,
      cupo de hoy y último ciclo. Cada cifra con la unidad y el período que la hacen legible
- [ ] **Gráfico de actividad** (`chart-area-interactive` del bloque) con dos series por día:
      **URLs consultadas** y **cambios de estado registrados**, con selector de rango. Las dos
      salen de datos directos —los lotes y el historial—, no de una estimación
- [ ] Últimos lotes de toda la cuenta, con su estado y su enlace
- [ ] Accesos a lo que se hace seguido: agregar un dominio y ver el listado. **No** a los sitemaps
      ni a la cobertura: son de un dominio concreto, y elegir uno por la persona es adivinar cuál
      le importa

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Sin dominios | Cuenta nueva que omitió el recorrido | `C-12` con «Agregar tu primer dominio» y el enlace a retomar el recorrido. Ni tarjetas en cero ni gráfico vacío |
| Sin datos todavía | Hay dominios, ninguna URL consultada | «Descubrimos N URLs. Todavía no consultamos ninguna.» **Nunca** «0 % indexado». El gráfico dice que no hay actividad registrada aún |
| Normal | Lo habitual | Todo lo de arriba, con la fecha de cada cifra |
| Algo roto | Cualquier dominio no operativo o credencial caída | El bloque de acción arriba de todo, antes de cualquier número |
| Cuenta sin poder operar | La credencial de la cuenta no sirve | RT-18: `C-18` manda, y las cifras se presentan como lo último que se pudo leer, con su fecha |

**Jerarquía**

1. **Lo que necesita acción**, si existe.
2. **Cuántas URLs tienen dato sobre el total**, y desde cuándo.
3. Las cifras del día: cupo y último ciclo.
4. La actividad en el tiempo.
5. El detalle: lotes recientes.

**Microcopy clave**

- Línea de honestidad: «1.240 de 5.000 URLs tienen dato de Google, en 3 dominios. Las otras 3.760
  todavía no se consultaron.»
- Todo en orden: «No hay nada que resolver. Comprobamos el acceso de tus 3 dominios hace 2 horas.»
- Algo roto: «2 dominios necesitan tu atención.» — con el motivo de cada uno en su renglón.
- Gráfico: «Consultas a Google y cambios de estado, por día.» Al pie: «Una consulta es una
  pregunta a Google sobre una URL. Que suba no significa que suba la indexación.»
  - Prohibido: «Evolución de la indexación», «Crecimiento», cualquier flecha de tendencia sobre
    una cifra de cobertura.
- Cupo del día: «Hoy consultamos 412 de las 2.000 que Google permite sobre tus propiedades.»

**Accesibilidad**

- [ ] El gráfico tiene su tabla equivalente accesible, o al menos el resumen en texto de lo que
      dibuja: un `<canvas>`/SVG sin equivalente no comunica nada a quien no lo ve
- [ ] El bloque de «lo que necesita acción» es una lista, con el motivo como texto y no sólo como
      color de un punto
- [ ] Cada cifra grande tiene su rótulo asociado, no un número suelto que el lector anuncia sin
      contexto

**Trampas del dominio**

- Una flecha «+12,5 %» heredada del bloque sobre una cifra de cobertura: sugiere una tendencia
  calculada sobre una muestra parcial, que es la restricción 8 escrita con un icono.
- Presentar el gráfico de actividad como si midiera resultados: consultar más URLs no indexa más
  URLs, y ponerlo como línea que sube invita a leerlo así (R-D, RT-05).
- Sumar las cifras de un dominio sin acceso a las del resto sin decir que están congeladas.
- Un tablero que se ve igual con todo funcionando y con dos dominios caídos.

---

### V1 · `/login` — `Login`

**Propósito**: que la persona entre a su cuenta con correo y contraseña, sin fricción y sin
enterarse de nada más.

**Elementos obligatorios**

- [ ] Nombre del producto arriba del formulario, sin promesa de producto en el subtítulo
- [ ] Campo “Correo electrónico”: `type="email"`, `autocomplete="username"`, `inputmode="email"`,
      foco automático al cargar
- [ ] Campo “Contraseña”: `type="password"`, `autocomplete="current-password"`, **sin** bloqueo de
      pegado
- [ ] Botón primario “Ingresar”, ancho completo, `type="submit"`
- [ ] Contenedor de error a nivel de formulario, por encima de los campos
- [ ] Nada más: sin “Crear cuenta”, sin planes, sin precios, sin “¿Olvidaste tu contraseña?”
      mientras esa capacidad no exista (RT-07; H-01 sigue abierto y se resuelve desde el panel
      interno)
- [ ] Es la única vista sin `C-01` y sin `C-18`: todavía no hay cuenta, así que no hay aviso de
      nivel cuenta ni zona de presentación que aplicar

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Vacío | Entrada normal | Formulario limpio, foco en el correo |
| Enviando | Tras el envío | Botón “Ingresando…”, `aria-busy`, campos siguen legibles |
| Error | Credenciales que no coinciden | Mensaje único a nivel de formulario, `role="alert"`, foco al mensaje, contraseña vaciada y correo conservado |
| Sesión expirada | Llega redirigido desde una vista protegida | Aviso “Tu sesión se cerró” y, tras ingresar, vuelve al destino que buscaba |

**Jerarquía**: el campo de correo. Es la única acción posible; todo lo demás es decoración y no
debe competir por atención.

**Microcopy clave**

- Título: “Ingresar a index-relay”
- Botón: “Ingresar” / en espera: “Ingresando…”
- Error de credenciales: “El correo o la contraseña no coinciden.”
- Sesión expirada: “Tu sesión se cerró. Ingresá de nuevo para continuar.”

> **Excepción deliberada a la regla de errores específicos**: acá el mensaje es a propósito
> ambiguo, para no revelar si un correo existe. Es el único lugar del producto donde un mensaje
> genérico es correcto.

**Accesibilidad**

- [ ] `<form>` real con `onSubmit`; Enter envía desde cualquier campo
- [ ] `<label>` asociada por `htmlFor` a cada campo; nada de sólo `placeholder`
- [ ] Error con `role="alert"`, campos con `aria-invalid="true"` y `aria-describedby` al mensaje
- [ ] Contraste AA en el botón primario y en el texto del error
- [ ] Objetivo táctil de 44 px de alto mínimo en el botón
- [ ] `:focus-visible` claramente distinguible sobre el fondo del formulario

**Trampas del dominio**

- Poner un eslogan que prometa indexación bajo el logo (RT-05).
- Ofrecer “Crear cuenta” o “Ver planes”: las cuentas se crean desde el panel interno y la
  facturación está apagada (RT-07).
- Ofrecer “¿Olvidaste tu contraseña?” apuntando a una pantalla que no existe: es exactamente el
  “cableado a medias” que prohíbe el principio VI.

---

### V2 · `/settings` — `Settings/Index`

**Propósito**: que alguien que nunca entró a Google Cloud termine con la credencial cargada,
verificada y autorizada en Search Console, sin ayuda externa y en menos de 15 minutos (SC-001).

**Elementos obligatorios**

- [ ] Encabezado de estado de la conexión con `Badge` + fecha de la última comprobación
      (`C-08`): “Sin configurar” · “Cargada, falta comprobarla” · “Verificada” · “Con un problema”
- [ ] Una única acción primaria visible según el estado (nunca dos botones compitiendo)
- [ ] Tarjeta del módulo **Search Console** con la credencial que tiene asignada y su estado
      (acceptance 7 de US1); el listado de módulos existe aunque hoy tenga una sola fila
- [ ] Guía embebida de cuatro pasos con `C-02 GuideStep`, cada uno con **objetivo**, **ruta exacta
      en las pantallas de Google** y **ejemplo del dato** (FR-054, FR-056)
- [ ] Paso 1 — campo “Identificador del proyecto” con validación de formato **local**, antes de
      cualquier llamada externa, y ejemplo visible
- [ ] Paso 2 — botón que abre la pantalla de habilitación de la API en Google y botón “Comprobar”
- [ ] Paso 3 — `C-03 KeyFileExample` con `type`, `project_id`, `private_key_id` y `client_email`
      resaltados, y la advertencia de que una API key no sirve (FR-006, FR-055)
- [ ] Paso 3 — zona de carga del archivo: `<input type="file" accept="application/json">` real,
      alcanzable por teclado, más arrastrar y soltar como agregado; muestra nombre y tamaño antes
      de enviar
- [ ] Paso 4 — bloque “Autorizar en Search Console” con `client_email` en `C-09 CopyableValue`, el
      permiso exigido (**propietario**) y la ruta exacta dentro de Search Console (FR-011)
- [ ] Ficha de la credencial guardada: `client_email`, huella, `private_key_id`, fecha de carga,
      estado y última comprobación. **Nunca** el contenido de la clave (RT-06)
- [ ] Botón “Comprobar la conexión” con resultado persistente y fechado (RT-11)
- [ ] Lista de propiedades accesibles detectadas tras la comprobación (`accessible_properties`)
- [ ] Acción secundaria “Reemplazar la credencial”, con `C-14` y la aclaración de que los dominios
      y el historial no se tocan (FR-012)
- [ ] `C-04 CredentialErrorPanel` con los cinco códigos mapeados
- [ ] Esta vista es el destino de `action_path` de los avisos `CREDENTIAL_INVALID` y
      `CREDENTIAL_REVOKED` (RT-18): al llegar desde el aviso, el bloque de la credencial recibe el
      foco y aparece desplegado, no hay que buscarlo

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Vacío | Cuenta sin credencial | Estado “Sin configurar”, la guía abierta en el paso 1, y ninguna funcionalidad de dominios ofrecida desde acá |
| Cargando | Durante “Comprobar” | Sólo ese botón en “Comprobando…”, `aria-busy`; el resto de la página sigue usable (RT-12) |
| Parcial (cargada sin comprobar) | `status = UNVERIFIED` | “Cargada, falta comprobarla”, con el botón de comprobación como acción primaria |
| Parcial (falta autorizar) | `NO_PROPERTIES` | Tratamiento de **paso pendiente**, no de falla: la clave sirve, falta agregarla en Search Console |
| Éxito | `status = VERIFIED` | “Verificada el …”, lista de propiedades accesibles y enlace a dar de alta un dominio |
| Error | Los otros cuatro códigos | `C-04` con título, explicación y una acción concreta |

**Jerarquía**

1. Una frase que dice en qué estado está la conexión, con su fecha.
2. Un único botón con el próximo paso concreto.
3. La guía, abierta en el paso que falta.

La ficha técnica de la credencial (huella, `private_key_id`) va **al final**: sirve para
identificar, no para decidir.

**Microcopy clave**

Encabezado por estado:

- “Todavía no conectaste tu cuenta de Google.”
- “Tu clave está cargada. Falta comprobar que funcione.”
- “Conexión verificada el 14 ago 2026, 09:12. Alcanza 3 propiedades.”
- “Hay un problema con la conexión.”

Paso 1 — identificador del proyecto:

- Objetivo: “Google agrupa todo lo que crees dentro de un proyecto. Necesitamos su identificador
  para saber a cuál mirar.”
- Ruta: “En Google Cloud, arriba a la izquierda, abrí el selector de proyectos y creá uno nuevo.
  El identificador aparece debajo del nombre.”
- Ejemplo: “Se ve así: `mi-proyecto-483920`. Son minúsculas, números y guiones.”
- Error de formato: “Ese identificador no tiene la forma que usa Google: entre 6 y 30 caracteres,
  en minúsculas, con números y guiones. Revisá que no hayas copiado el **nombre** del proyecto en
  lugar de su **identificador**.”

Paso 2 — habilitar la API:

- Objetivo: “Un proyecto nuevo viene con todas las APIs apagadas. Hay que encender la de Search
  Console para que podamos consultarla.”
- Botón: “Abrir la pantalla para habilitarla”
- Nota: “Después de habilitarla puede tardar un par de minutos en estar activa. Si la comprobación
  falla, esperá y volvé a intentar.”

Paso 3 — la clave:

- Objetivo: “Necesitamos una **cuenta de servicio** con su archivo de clave. Es un usuario que no
  es una persona: sirve para que la plataforma consulte por vos.”
- Advertencia: “Una API key de Google **no** sirve para esto: no da acceso a los datos privados de
  una propiedad. El archivo que buscás es un `.json` y adentro dice `"type": "service_account"`.”
- Zona de carga: “Arrastrá acá el archivo `.json` o elegilo desde tu computadora.”
- Error de archivo equivocado: “Este archivo no es una clave de cuenta de servicio: dice
  `"type": "authorized_user"`. Volvé a Google Cloud, entrá a la cuenta de servicio que creaste y
  descargá una clave nueva en formato JSON.”
- Error de archivo incompleto: “Al archivo le falta el campo `client_email`. Puede haberse cortado
  al copiarlo. Descargá la clave de nuevo y subila sin editarla.”
- Tras guardar: “Guardamos tu clave cifrada. Por seguridad no vamos a volver a mostrarla; la vas a
  reconocer por su huella `a1b2…c3d4`.”

Paso 4 — autorizar en Search Console:

- Objetivo: “Google todavía no sabe que esta cuenta de servicio puede leer tu sitio. Tenés que
  agregarla vos, una vez por propiedad.”
- Instrucción: “Copiá esta dirección y agregala en Search Console, en **Configuración › Usuarios y
  permisos › Agregar usuario**, con permiso de **Propietario**.”
- Advertencia: “Con un permiso menor —lector o usuario completo— la inspección de URLs no funciona
  y Google no avisa por qué.”

Los cinco errores de comprobación, cada uno con su acción:

| Código | Título | Explicación | Acción |
|---|---|---|---|
| `INVALID_KEY` | “La clave ya no sirve” | “Google rechazó esta clave: puede haber sido eliminada o revocada desde Google Cloud.” | “Subir una clave nueva” + enlace a las claves de la cuenta de servicio. “Tus dominios y tu historial no se tocan.” |
| `API_NOT_ENABLED` | “Falta habilitar la API de Search Console” | “La clave funciona, pero en el proyecto `mi-proyecto-483920` la API de Search Console está apagada.” | “Abrir la pantalla para habilitarla” + “Volver a comprobar”. “Puede tardar unos minutos en estar activa.” |
| `PROJECT_MISMATCH` | “El proyecto y la clave no coinciden” | “Declaraste el proyecto `mi-proyecto-483920`, pero el archivo de clave pertenece a `otro-proyecto-119203`.” | “Corregir el identificador” (lleva al paso 1 con el campo enfocado) o “Subir la clave del proyecto correcto”. |
| `NO_PROPERTIES` | “Falta autorizar la cuenta de servicio” | “La clave funciona. Todavía no figura como propietaria en ninguna propiedad de Search Console, así que no hay nada que podamos leer.” | “Copiar la dirección” + “Abrir Search Console” + “Volver a comprobar”. |
| `PROVIDER_UNAVAILABLE` | “No pudimos hablar con Google” | “Google no respondió a tiempo. No es un problema de tu credencial ni de tu configuración.” | “Volver a comprobar en unos minutos”. La credencial **conserva** su estado anterior. |

**Accesibilidad**

- [ ] La guía es una `<ol>`; el paso vigente lleva `aria-current="step"`
- [ ] El resultado de cada comprobación va en una región viva: `role="status"` cuando pasa,
      `role="alert"` cuando falla
- [ ] La carga de archivo funciona sólo con teclado; la zona de arrastre es un agregado y está
      marcada `aria-hidden` si duplica al `<input>`
- [ ] `C-09` anuncia “Dirección copiada” por región viva, no sólo con un cambio de icono
- [ ] El ejemplo del archivo JSON es texto seleccionable en `<pre>`, no una imagen, y los campos
      resaltados llevan además una marca textual (no sólo color)
- [ ] La huella se muestra en fuente monoespaciada, con `aria-label` que la deletrea por grupos

**Trampas del dominio**

- Mostrar el contenido de la clave “para que el usuario confirme que subió la correcta”: el ejemplo
  anonimizado existe precisamente para no tener que hacer eso (R-E, FR-009).
- Devolver un único mensaje “No se pudo verificar la credencial”: los cinco casos exigen cinco
  textos y cinco acciones (SC-002). Un error genérico acá es un defecto, no una simplificación.
- Pintar `NO_PROPERTIES` en rojo: es el estado **esperado** justo después de subir la clave.
- Ofrecer “verificar la propiedad con un registro TXT” como alternativa: capacidad apagada (RT-07).
- Cerrar el paso 4 diciendo “¡Listo! Ya vas a aparecer en Google” (RT-05).

---

### V3 · `/onboarding` — `Onboarding/Wizard`

**Propósito**: llevar a alguien que entra por primera vez desde cero hasta su primer lote encolado,
comprobando de verdad cada paso, en menos de 30 minutos (SC-003).

**Elementos obligatorios**

- [ ] `C-16 StepProgress` con los siete pasos nombrados en español y su estado individual:
      `GOOGLE_PROJECT`, `ENABLE_API`, `SERVICE_ACCOUNT_KEY`, `AUTHORIZE_PROPERTY`, `ADD_DOMAIN`,
      `ADD_SITEMAP`, `FIRST_BATCH`
- [ ] Panel del paso actual con `C-02 GuideStep`: objetivo, ruta exacta en Google, ejemplo del dato
- [ ] Botón “Comprobar” en cada paso; el botón “Continuar” **aparece recién cuando la comprobación
      pasa** (no se muestra deshabilitado antes)
- [ ] Mensaje de “qué falta y dónde resolverlo” cuando la comprobación no pasa (`missing`)
- [ ] Enlace por paso “Prefiero hacerlo desde el formulario completo”, que lleva a la vista
      equivalente sin perder el progreso (FR-018)
- [ ] Acción secundaria persistente “Omitir el recorrido”, con lo que implica explicado
- [ ] Al retomar: los pasos cumplidos marcados y los objetos ya creados nombrados, no un formulario
      vacío (FR-019)
- [ ] Pantalla final que enlaza al lote encolado y a la cobertura del dominio

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Primera vez | Cuenta sin progreso | Paso 1 abierto, con una línea sobre qué se va a lograr y cuánto lleva |
| En curso / retomado | Vuelve más tarde | Paso guardado abierto, los cumplidos con tilde y su fecha, los objetos ya creados nombrados: “Dominio ya dado de alta: ejemplo.com” |
| Comprobando | Tras “Comprobar” | Ese botón en espera; el resto del asistente sigue navegable |
| Paso no cumplido | La comprobación falla | Qué falta, en una frase, con la acción concreta y sin marcar el paso como hecho |
| Omitido | Tras “Omitir” | Sale al tablero (V0), con un aviso discreto y permanente para retomarlo |
| Completado | Todos los pasos | No se vuelve a ofrecer al ingresar; queda accesible por URL directa mostrando el resumen |

**Jerarquía**: el **objetivo del paso actual** en una frase, antes que la ruta y antes que el
progreso. Quien no sabe qué es Google Cloud necesita saber para qué sirve lo que le están pidiendo
antes de saber dónde hacer clic.

**Microcopy clave**

- Ofrecimiento inicial: “Te acompañamos a dejar todo conectado. Son siete pasos y podés salir
  cuando quieras: lo que hagas queda guardado.” · Botones: “Empezar” / “Prefiero hacerlo por mi
  cuenta”
- Nombres de los pasos: “1. Crear el proyecto en Google” · “2. Habilitar la API” · “3. Crear la
  cuenta de servicio y subir su clave” · “4. Autorizar la cuenta en Search Console” · “5. Dar de
  alta tu dominio” · “6. Registrar tu sitemap” · “7. Lanzar la primera inspección”
- Comprobación pendiente: “Comprobar este paso”
- Comprobación que falla, por paso:
  - Paso 4: “Todavía no vemos la propiedad `sc-domain:ejemplo.com` entre las que puede leer esta
    cuenta de servicio. Revisá que la hayas agregado con permiso de **Propietario** y volvé a
    comprobar.”
  - Paso 6: “No pudimos descargar `https://ejemplo.com/sitemap.xml`: el servidor respondió 404.
    Revisá la dirección o probá con `https://ejemplo.com/sitemap_index.xml`.”
- Al retomar: “Retomamos donde quedaste. Los pasos 1 a 3 ya estaban listos.”
- Omitir: “Vas a poder hacer todo desde los formularios completos. El recorrido queda disponible
  para retomarlo cuando quieras.”
- Cierre (crítico, RT-05): “Tu primer lote quedó encolado. Cuando termine vas a ver el estado que
  informó Google para cada URL, con su fecha. Un ciclo completo sobre 5.000 URLs lleva unos 3
  días.”
  - Prohibido: “Listo, tus URLs se van a indexar” / “En 3 días vas a estar indexado”.

**Accesibilidad**

- [ ] El progreso es una `<ol>` con `aria-current="step"` en el paso vigente y texto de estado por
      paso (“cumplido”, “pendiente”), no sólo un icono
- [ ] Al avanzar de paso, el foco se mueve al encabezado del paso nuevo (`tabIndex={-1}` + `focus()`)
- [ ] Resultado de la comprobación en `role="status"` / `role="alert"` según pase o falle
- [ ] El asistente completo es recorrible con teclado, incluido el enlace de escape a los
      formularios completos
- [ ] Ningún paso depende de arrastrar y soltar como única vía

**Trampas del dominio**

- Marcar un paso como cumplido porque el usuario lo visitó o apretó “Siguiente”: sólo lo marca una
  comprobación efectiva (FR-017, R16).
- Al retomar, mostrar el formulario de alta de dominio vacío y crear un segundo `ejemplo.com`
  (FR-019, SC-009).
- Bloquear funcionalidad a quien omite: el recorrido es un atajo, no una llave (FR-018).
- Prometer en el paso 7 un resultado de indexación (RT-05).
- Deshabilitar “Continuar” sin explicar por qué: por eso el botón no existe hasta que la
  comprobación pasa.

---

### V4 · `/domains` — `Domains/Index`

**Propósito**: ver de un vistazo cuáles dominios están operativos y cuáles necesitan una acción del
usuario.

**Elementos obligatorios**

- [ ] `C-11 DataTable` con: dominio, forma de la propiedad, `C-06 AccessStateBadge`, fecha de la
      última comprobación de acceso (`C-08`), notificaciones activadas
- [ ] **Resumen de cobertura por fila** (`coverage_summary`, FR-062): `known_urls` sobre
      `total_urls` como dato principal, después `indexed` y `not_indexed`, y la fecha del último
      ciclo (`last_cycle_at`) con `C-08`
- [ ] El denominador `known_urls / total_urls` va **siempre delante** de los conteos por estado, en
      la misma celda y sin necesidad de pasar el mouse (RT-03)
- [ ] Orden por defecto que pone arriba los dominios que requieren acción (`AWAITING_ACCESS`,
      `ACCESS_LOST`)
- [ ] Filtro por estado de acceso, reflejado en la querystring (RT-09); el aviso
      `DOMAINS_ACCESS_LOST` entra a esta vista con ese filtro ya aplicado y visible
- [ ] Acción primaria “Agregar dominio”
- [ ] `C-18 AccountNotice` arriba, con la credencial caída o los dominios con acceso perdido
      (RT-18); mientras `can_operate` sea falso, ninguna fila se presenta como operativa
- [ ] `C-12 EmptyState` para la cuenta sin dominios

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Vacío | Sin dominios | “Todavía no agregaste ningún dominio.” + qué va a pasar al agregarlo + botón “Agregar dominio” |
| Sin credencial verificada | El módulo no resuelve credencial | Aviso arriba de la tabla: “Antes de agregar un dominio necesitás dejar la conexión con Google verificada.” + “Ir a la configuración”. El botón de alta lleva a `/settings`, no a un formulario que va a fallar |
| Cuenta sin poder operar | `can_operate` falso | `C-18` arriba con la acción de resolución; cada fila conserva su `access_state` pero agrega “sin credencial para consultarlo” y no ofrece acciones que llamen a Google (RT-18) |
| Cargando | Primera carga | Esqueleto de 5 filas con la misma altura que las reales |
| Sin ciclo todavía | `coverage_summary` con `last_cycle_at` nulo | “Sin datos todavía” en la celda de cobertura, no ceros: un cero se lee como “ninguna indexada” |
| Sin resumen | `coverage_summary` nulo (dominio recién creado o sin URLs) | “Todavía no descubrimos URLs” con enlace a los sitemaps del dominio |
| Con datos | Normal | Tabla con el estado y su fecha, y el resumen con su denominador |
| Error | Fallo del servidor | `C-13` con acción “Volver a cargar” |

**Jerarquía**: la columna de estado de acceso. El nombre del dominio identifica; el estado es lo
que dispara la acción.

**Microcopy clave**

Etiquetas de los cinco estados de acceso, cada una con su explicación de una línea:

| Estado | Etiqueta | Explicación |
|---|---|---|
| `AWAITING_ACCESS` | “Esperando acceso” | “Falta autorizar la cuenta de servicio en Search Console.” |
| `OPERATIONAL` | “Operativo” | “Podemos leer la propiedad y monitorear sus URLs.” |
| `ACCESS_LOST` | “Acceso perdido” | “Dejamos de poder leer esta propiedad. El monitoreo está detenido; el historial se conserva.” |
| `ACCESS_REVOKED` | “Acceso revocado” | “El acceso se revocó desde la administración. El historial se conserva.” |
| `SUSPENDED` | “Suspendido” | “El monitoreo está detenido.” |

- Estado vacío: “Todavía no agregaste ningún dominio. Cuando agregues uno, vas a tener que
  autorizar nuestra cuenta de servicio en su propiedad de Search Console para que podamos
  consultarla.”
- Resumen de cobertura en la fila: “1.240 de 5.000 URLs con dato · 980 indexadas · 260 no
  indexadas · último ciclo hace 6 horas”
- Sin ciclo todavía: “Sin datos todavía · 5.000 URLs descubiertas”
- Encabezado de la columna: “Cobertura (con dato / total)”

**Accesibilidad**

- [ ] `<caption>` o encabezado accesible que diga qué lista es y cuántas filas tiene
- [ ] `aria-sort` en las columnas ordenables
- [ ] Toda la fila no es un enlace: el enlace es el nombre del dominio, para que el lector de
      pantalla anuncie un destino con sentido
- [ ] Los badges llevan texto, no sólo color (RT-04)
- [ ] El resumen de cobertura se lee completo en una sola frase por lector de pantalla, con su
      denominador incluido; no son cuatro números sueltos sin etiqueta
- [ ] `C-18` está antes que la tabla en el orden del DOM y se anuncia con `role="status"`

**Trampas del dominio**

- Pintar “Esperando acceso” como error rojo: es un paso pendiente del usuario, no una falla del
  sistema (`C-06` lo separa en dos familias).
- Mostrar `indexed` sin `known_urls / total_urls` al lado: “980 indexadas” sobre un sitio del que
  se consultó una cuarta parte insinúa una cobertura total que no existe (R-B, FR-062).
- Convertir el resumen en un porcentaje de una sola cifra, o en una barra sin números.
- Mostrar ceros cuando todavía no corrió ningún ciclo: un cero se lee como “ninguna indexada”, que
  es una afirmación sobre Google que no podemos hacer.
- Mostrar filas “Operativo” mientras `can_operate` es falso (RT-18): el dominio está bien, pero no
  hay con qué consultarlo.
- Mostrar “Acceso perdido” sin decir que el historial se conserva: el miedo a perder datos es lo
  primero que aparece.

---

### V5 · `/domains/new` — `Domains/Create`

**Propósito**: dar de alta un dominio con la **forma de propiedad correcta**, que es la causa más
común de que después “Google no encuentre la propiedad” (R3).

**Elementos obligatorios**

- [ ] Campo “Dominio” con normalización visible: si escribe `https://Ejemplo.com/`, se muestra
      “Vamos a guardarlo como `ejemplo.com`”
- [ ] Selector de forma de propiedad con **las dos opciones explicadas y con ejemplo**, no con sus
      nombres técnicos a secas
- [ ] Vista previa del `property_uri` que se va a usar, editable: `sc-domain:ejemplo.com` o
      `https://ejemplo.com/`
- [ ] Aviso previo, antes de enviar, de que el paso siguiente es autorizar la cuenta de servicio,
      con el `client_email` ya visible
- [ ] Botón “Dar de alta el dominio”, más “Cancelar” que vuelve al listado
- [ ] Manejo de `409` con enlace a la ficha existente

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Vacío | Entrada normal | Formulario con el foco en el dominio y la explicación de las dos formas visible, no colapsada |
| Enviando | Tras el envío | Botón en “Dando de alta…”, campos conservados |
| Error de validación (`400`) | Formato inválido | Mensaje junto al campo, valores conservados, foco al primer error (RT-17) |
| Duplicado (`409`) | Ya existe en la cuenta | “Ese dominio ya está dado de alta en tu cuenta.” + “Ver su ficha” |
| Sin credencial | El módulo no resuelve credencial | La vista no se ofrece: redirige a `/settings` con el motivo |

**Jerarquía**: el selector de forma de propiedad. El campo del dominio es obvio; la forma de la
propiedad es la decisión que puede arruinar todo lo que sigue.

**Microcopy clave**

- Explicación de las dos formas:
  - “**Propiedad de dominio** — si en Search Console lo agregaste sin `https://` adelante y cubre
    todos los subdominios. Se ve así: `sc-domain:ejemplo.com`.”
  - “**Prefijo de URL** — si lo agregaste con `https://` adelante. Distingue el esquema, el
    subdominio y la barra final. Se ve así: `https://ejemplo.com/`.”
- Nota: “No son intercambiables. Si elegís la que no es, Google va a responder que la propiedad no
  existe.”
- Después del alta: “Dimos de alta `ejemplo.com`. Todavía no podemos leerlo: falta autorizar
  nuestra cuenta de servicio en Search Console.”
- Duplicado: “`ejemplo.com` ya está dado de alta en tu cuenta.”

**Accesibilidad**

- [ ] El selector de forma de propiedad es un grupo de radios con `<fieldset>` y `<legend>`, no un
      desplegable: las dos opciones tienen que verse a la vez para poder compararlas
- [ ] La vista previa del `property_uri` se anuncia como región viva al cambiar la selección
- [ ] `autocomplete="url"` en el campo del dominio; `inputmode="url"`
- [ ] Los ejemplos en `<code>` con contraste suficiente, no en gris claro decorativo

**Trampas del dominio**

- Adivinar la forma de la propiedad a partir del hostname y no dejar cambiarla.
- Dar a entender que al dar de alta ya empieza el monitoreo: nace en `AWAITING_ACCESS` (RT-05).
- Esconder el `client_email` hasta después del alta: mostrarlo antes ahorra un viaje.

---

### V6 · `/domains/{id}` — `Domains/Show`

**Propósito**: saber si el dominio está operativo, qué falta si no lo está, y cuánto cupo queda hoy.

**Elementos obligatorios**

- [ ] Encabezado: hostname, forma de propiedad, `C-06 AccessStateBadge` y fecha de la última
      comprobación
- [ ] Bloque de acceso: `client_email` en `C-09`, instrucción con el permiso de **propietario**,
      botón “Comprobar acceso” y el resultado del último intento, fechado y persistente
- [ ] `C-10 QuotaMeter` del día: total, reserva manual, usado automático, usado manual y saldo de
      cada cupo, con la fecha del presupuesto
- [ ] Accesos a las tres vistas hijas: cobertura, sitemaps, lotes — con un número que las
      justifique cuando exista
- [ ] **Bloque de configuración editable** (`PATCH /domains/{id}`, FR-057) con tres controles:
      interruptor de notificaciones, presupuesto diario de inspección y reserva manual
- [ ] El interruptor de notificaciones guarda al instante y confirma en el lugar; los dos campos
      numéricos guardan con un botón explícito, porque cambian el comportamiento del ciclo
- [ ] Validación en pantalla antes de enviar: el presupuesto diario no puede quedar por debajo de
      la reserva manual, y ninguno de los dos puede ser negativo
- [ ] Efecto del cambio dicho en números, no en abstracto: cuántas inspecciones automáticas por día
      quedan y cuántos días pasa a llevar un ciclo completo
- [ ] Acción “Inspeccionar ahora”, con confirmación que dice qué cupo consume
- [ ] Banner propio cuando el estado no es `OPERATIONAL`, por encima de todo lo demás; y `C-18`
      cuando el problema es de la cuenta y no del dominio (RT-18)

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Esperando acceso | `AWAITING_ACCESS` | La vista es, sobre todo, la instrucción de autorización: dirección para copiar, permiso exigido, botón de comprobación. Cobertura y lotes quedan visibles pero vacíos y explicados |
| Comprobando | Tras “Comprobar acceso” | Sólo ese botón en espera |
| Operativo | `OPERATIONAL` | Cupo del día, accesos a las vistas hijas, acciones habilitadas |
| Acceso perdido | `ACCESS_LOST` | Banner con la fecha en que se detectó, qué revisar (“¿Se quitó la cuenta de servicio de la propiedad?”), botón “Volver a comprobar” y la aclaración de que el historial se conserva |
| Revocado / suspendido | `ACCESS_REVOKED`, `SUSPENDED` | Todo en sólo lectura, con el historial accesible y sin acciones que vayan a fallar |
| Cupo agotado | `429` al encolar | “El cupo de hoy se agotó” con el detalle de los dos cupos y cuándo se renueva |
| Guardando configuración | `PATCH` en curso | Sólo el control tocado en espera; el resto de la ficha sigue usable (RT-12) |
| Configuración inválida | `400` del `PATCH` | Mensaje junto al campo, con el valor conservado y el porqué en números (RT-08, RT-17) |
| Error del proveedor | `PROVIDER_UNAVAILABLE` | `C-13`, sin cambiar el estado del dominio |

**Jerarquía**: el estado de acceso. Sin acceso, el cupo, los sitemaps y la cobertura son ruido; la
vista tiene que reordenarse alrededor de eso.

**Microcopy clave**

- Autorización: “Copiá esta dirección y agregala en Search Console, en **Configuración › Usuarios y
  permisos › Agregar usuario**, con permiso de **Propietario**. Con un permiso menor la inspección
  de URLs no funciona.”
- Los tres errores de `check-access`:

| Código | Mensaje | Acción |
|---|---|---|
| `PERMISSION_DENIED` | “Encontramos la propiedad, pero nuestra cuenta de servicio no tiene permiso para leerla. Revisá que figure como **Propietario** y no como lector.” | “Copiar la dirección” + “Volver a comprobar” |
| `PROPERTY_NOT_FOUND` | “En Search Console no existe una propiedad con la forma `sc-domain:ejemplo.com`. Puede estar registrada como prefijo de URL: `https://ejemplo.com/`.” | “Cambiar la forma de la propiedad” |
| `PROVIDER_UNAVAILABLE` | “Google no respondió a tiempo. No cambiamos el estado de tu dominio.” | “Volver a comprobar” |

- Cupo: “Cupo de hoy (17 ago): 2.000 consultas. Usadas: 1.412 automáticas y 12 manuales. Quedan 388
  automáticas y 188 reservadas para lo que dispares a mano.”
- Confirmación de inspección manual: “Vas a inspeccionar 25 URLs usando el cupo reservado al uso
  manual. Te quedan 188 de ese cupo hoy.”
- Notificaciones: “Recibir avisos por correo de este dominio” + “Te avisamos cuando termina un lote
  y cuando cambia la cobertura de alguna URL. Como máximo un correo por día por dominio.”
- Presupuesto diario: “Consultas por día” + ayuda: “Google permite hasta 2.000 consultas por día
  por propiedad. Bajarlo hace más lento el ciclo; subirlo por encima de lo que Google permite hace
  que las consultas de más fallen.”
- Reserva manual: “Reservadas para uso manual” + ayuda: “Este cupo no lo toca el ciclo automático.
  Queda disponible para lo que dispares vos desde acá o desde la API.”
- Efecto del cambio, antes de guardar: “Con 1.500 por día y 200 reservadas, el ciclo automático usa
  1.300 consultas diarias y recorrer las 5.000 URLs pasa a llevar unos 4 días.”
- Error de validación: “La reserva manual (300) no puede ser mayor que el presupuesto diario (200).
  Subí el presupuesto o bajá la reserva.”
- Confirmación de guardado: “Guardamos la configuración. Se aplica desde el próximo ciclo.”
- Acceso perdido: “Dejamos de poder leer esta propiedad el 15 ago 2026. El monitoreo está detenido
  y el historial se conserva completo.”

**Accesibilidad**

- [ ] El banner de estado no operativo es lo primero en el orden del DOM, no sólo lo primero
      visualmente
- [ ] `C-10` no comunica el saldo sólo con la barra: los números están en texto
- [ ] El interruptor de notificaciones es un `Switch` con `role="switch"` y `aria-checked`, con
      etiqueta visible y confirmación del cambio en región viva
- [ ] Los campos numéricos son `<input type="number">` con `min`, etiqueta visible y texto de ayuda
      enlazado por `aria-describedby`; el error de validación usa `aria-invalid`
- [ ] El resultado de la comprobación de acceso queda escrito, no sólo en un mensaje efímero

**Trampas del dominio**

- Presentar el cupo consumido como “progreso hacia la cobertura completa”: son cosas distintas y
  confundirlas insinúa que la cobertura parcial es total (R-B, restricción 8).
- Mostrar un único cupo: el trabajo automático no puede tocar la reserva manual, y eso es una
  garantía del principio II que tiene que verse.
- Ofrecer “verificar la propiedad” con un registro TXT (RT-07).
- Decir “Vamos a indexar tus URLs” en la confirmación de inspección (RT-05).
- Dejar subir el presupuesto por encima de lo que Google permite sin decir qué va a pasar: la cuota
  es del dueño del sitio y agotarla rompe también sus propias herramientas (principio II).
- Presentar el presupuesto como una palanca de velocidad de indexación: regula cuánto consultamos,
  no cuánto rastrea Google (RT-05).

---

### V7 · `/domains/{id}/coverage` — `Coverage/Index`

**Propósito**: saber qué URLs están indexadas, cuáles no y por qué, sabiendo en todo momento qué
parte del sitio tiene dato y qué parte no.

**Es la vista donde más fácil se miente.** Todo acá se diseña contra esa tentación.

**Elementos obligatorios**

- [ ] **Línea de honestidad**, primero y en tamaño de titular: cuántas URLs tienen dato sobre el
      total, y desde cuándo
- [ ] `C-15 CoverageSummary` con dos bloques físicamente separados: “Con dato de Google” (los nueve
      estados) y “Sin consultar todavía” (`UNKNOWN`)
- [ ] Estimación del ciclo completo (`full_cycle_estimate_days`) redactada sin promesa
- [ ] `C-11 DataTable` con columnas: URL, estado (`C-05`), **fecha de obtención**, motivo declarado
      por Google (`raw_coverage_state`), canónica de Google cuando difiere de la declarada, último
      rastreo, presente en sitemap
- [ ] Filtros por estado y por presencia en sitemap, en la querystring
- [ ] **Panel lateral de historial de una URL** (`Sheet`, FR-060): se abre desde la fila, sin salir
      de la vista ni perder el filtro, y muestra el estado vigente más la línea de tiempo de cada
      cambio con su fecha de obtención y el lote que lo produjo
- [ ] La URL abierta en el panel viaja en la querystring, así que el panel es enlazable y el botón
      Atrás lo cierra (RT-09)
- [ ] **Exportación con dos comportamientos según el volumen** (FR-058): por debajo del umbral
      descarga el CSV al instante; por encima crea un lote de exportación, responde `202` y deja un
      aviso dentro de la plataforma al terminar. La pantalla dice cuál de los dos va a ocurrir
      **antes** de que el usuario confirme
- [ ] La exportación arrastra los mismos filtros activos, y eso se dice con números
- [ ] Paginación del servidor con el total visible
- [ ] Enlace al lote que produjo los datos más recientes

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Sin URLs | No hay sitemaps leídos | “Todavía no descubrimos ninguna URL.” + “Registrar un sitemap” |
| Todo sin consultar | Hay URLs, ninguna inspeccionada | “Descubrimos 5.000 URLs. Todavía no consultamos ninguna.” **Nunca** “0 % indexado”. Con la fecha estimada del primer ciclo |
| Cargando | Primera carga o cambio de filtro | Esqueleto de tabla; el resumen conserva su último valor con su fecha |
| Parcial | Lo normal en un sitio grande | Resumen con denominador explícito y el bloque de `UNKNOWN` con su conteo |
| Éxito | Todas con dato | Igual, con el bloque de `UNKNOWN` en cero y la ventana de fechas del dato |
| Sin acceso | El dominio no está `OPERATIONAL` | Los datos históricos siguen visibles, con un aviso de que están congelados desde tal fecha |
| Historial cargando | Se abre el panel | Esqueleto de la línea de tiempo dentro del panel; la tabla de atrás no se toca |
| Historial de una sola entrada | La URL se inspeccionó una vez y nunca cambió | “Un solo dato desde el 12 ago 2026. No hubo cambios de estado desde entonces.” — no un panel vacío |
| Exportación inmediata | Por debajo del umbral | Descarga del archivo y confirmación en el lugar, con la cantidad de filas exportadas |
| Exportación encolada | Por encima del umbral (`202`) | Aviso persistente con el lote enlazado: qué se está exportando y que se avisa al terminar (RT-13) |
| Exportación lista | El lote terminó | El archivo se baja desde la pantalla y desde la ficha del lote, con su cantidad de filas, su tamaño y hasta cuándo dura |
| Exportación vencida | Pasó la retención | Se dice que venció y se ofrece volver a pedirla; nunca un enlace que da 404 |
| Error | Fallo del servidor | `C-13` |

**Jerarquía**

1. **Cuántas URLs tienen dato sobre el total.**
2. El conteo por estado, con su denominador.
3. La tabla.

El motivo: un porcentaje de indexación calculado sobre una muestra parcial es la mentira más
barata de cometer y la más cara de sostener. Si lo primero que se lee es el denominador, el resto
del tablero se interpreta bien solo.

**Microcopy clave**

- Línea de honestidad: “1.240 de 5.000 URLs tienen dato de Google. Las otras 3.760 todavía no se
  consultaron.”
- Ventana de fechas: “Los datos van del 12 ago 2026 al 17 ago 2026.”
- Estimación: “Con el cupo actual de 2.000 consultas por día, recorrer las 5.000 URLs lleva unos 3
  días.”
  - Prohibido: “En 3 días vas a estar indexado” / “Faltan 3 días para completar la indexación”.
- Bloque de `UNKNOWN`: “Sin consultar todavía — 3.760 URLs. Todavía no le preguntamos a Google por
  estas URLs. No significa que no estén indexadas.”

Los diez estados, con su etiqueta y su explicación de una línea:

| Estado | Etiqueta | Explicación | Familia |
|---|---|---|---|
| `UNKNOWN` | “Sin consultar todavía” | “Todavía no le preguntamos a Google por esta URL.” | sin dato |
| `INDEXED` | “Indexada” | “Google informó que está en su índice.” | con dato positivo |
| `CRAWLED_NOT_INDEXED` | “Rastreada, sin indexar” | “Google la visitó y decidió no incluirla.” | con dato negativo |
| `DISCOVERED_NOT_INDEXED` | “Descubierta, sin rastrear” | “Google sabe que existe pero todavía no la visitó.” | con dato negativo |
| `DUPLICATE_CANONICAL` | “Duplicada con otra canónica” | “Google eligió otra URL como versión principal.” | con dato negativo |
| `EXCLUDED_NOINDEX` | “Excluida por noindex” | “La página pide explícitamente no ser indexada.” | con dato negativo |
| `BLOCKED_ROBOTS` | “Bloqueada por robots.txt” | “El archivo robots.txt impide que Google la visite.” | con dato negativo |
| `FETCH_ERROR` | “Error al descargarla” | “Google no pudo obtener la página.” | con dato negativo |
| `REDIRECT` | “Redirecciona a otra URL” | “Google siguió una redirección desde esta dirección.” | con dato negativo |
| `OTHER_NOT_INDEXED` | “Sin indexar, otro motivo” | “Google informó un motivo que no reconocemos. El texto original está en la columna de motivo.” | con dato negativo |

- Columna de fecha: encabezado “Dato obtenido”, con `C-08`.
- Exportación por debajo del umbral: “Exportar las 412 URLs filtradas. Cada fila incluye el estado,
  el motivo que informó Google y la fecha en que lo obtuvimos.”
- Exportación por encima del umbral, **antes** de confirmar: “Son 128.400 URLs: la exportación se
  prepara en segundo plano y te avisamos acá cuando esté lista. Podés seguir usando la plataforma
  mientras tanto.”
- Exportación encolada: “Estamos preparando la exportación de 128.400 URLs. Te avisamos cuando esté
  lista. Ver el lote.”
  - El aviso es el de la plataforma, no un correo: el correo quedó fuera de alcance por decisión
    del owner, y prometer un canal que nadie atiende es peor que no prometer ninguno.
- Exportación lista: “La exportación que pediste hace 12 minutos está lista: 128.400 filas, 18,2 MB.
  Se puede bajar hasta el 26 ago 2026.”
- Panel de historial — encabezado: “Historial de `https://ejemplo.com/producto/42`”
- Panel de historial — línea de tiempo: “17 ago 2026, 04:12 — Rastreada, sin indexar · 12 ago 2026,
  03:58 — Indexada (primer dato)”
- Panel de historial — sin cambios: “Un solo dato, del 12 ago 2026. No hubo cambios de estado desde
  entonces.”
- Panel de historial — pie: “Cada línea es una respuesta de Google con su fecha. Guardamos un
  registro nuevo sólo cuando el estado cambia.”
- Filtro “no indexadas”: “No indexadas (412)” con nota al pie del filtro: “No incluye las 3.760 sin
  consultar.”

**Accesibilidad**

- [ ] El resumen es texto, no sólo un gráfico; si hay gráfico, tiene su tabla equivalente accesible
- [ ] La tabla tiene encabezados `<th scope="col">`, `aria-sort` y `<caption>` con el filtro activo
- [ ] Al cambiar un filtro se anuncia el nuevo total en región viva: “412 URLs con el filtro
      aplicado”
- [ ] Las URLs largas no rompen el ancho ni obligan a scroll horizontal de página (RT-09)
- [ ] Los diez estados se distinguen sin color: cada uno lleva icono propio y texto
- [ ] El panel lateral atrapa el foco mientras está abierto, se cierra con `Escape` y devuelve el
      foco a la fila desde la que se abrió (RT-15)
- [ ] La línea de tiempo del panel es una `<ol>` con la fecha como texto, no una decoración con
      puntos y líneas sin equivalente accesible
- [ ] El resultado de la exportación se anuncia en región viva, y la variante encolada queda además
      escrita en pantalla: un toast no alcanza (RT-11)

**Trampas del dominio**

- Un gráfico de torta donde `UNKNOWN` es una porción gris más y el porcentaje se calcula sobre el
  total: es la forma más directa de convertir “no sabemos” en “no indexada” (R-B).
- Un titular “85 % indexado” cuando se consultó el 20 % del sitio (restricción 8).
- Una columna de estado sin su fecha, o la fecha sólo en un globo emergente que no sobrevive a la
  exportación (R-A, FR-047, SC-005).
- Incluir `UNKNOWN` dentro del filtro “no indexadas”.
- Ordenar por estado por defecto y dejar las sin consultar al final, invisibles.
- Escribir “Google va a indexar estas URLs en los próximos días” junto a la estimación del ciclo.
- Presentar la línea de tiempo del historial como si los huecos entre registros fueran estados
  desconocidos: sólo guardamos un registro cuando el estado **cambia**, y el panel lo dice.
- Ofrecer la exportación grande como si fuera inmediata y dejar al usuario esperando una descarga
  que nunca arranca: el comportamiento se anuncia antes de confirmar (FR-058).
- Dejar que la exportación se lleve un filtro distinto del que está en pantalla.

---

### V8 · `/domains/{id}/sitemaps` — `Sitemaps/Index`

**Propósito**: saber qué sitemaps conoce la plataforma, cuándo los leyó y qué le envió a Google.

**Elementos obligatorios**

- [ ] Formulario de alta con un solo campo (`location`) y su ejemplo
- [ ] Lista jerárquica: los sitemaps descubiertos se muestran anidados bajo el índice que los
      declaró (`parent_id`), con sangría y relación explícita en texto
- [ ] Por fila: ubicación, tipo (`INDEX`/`URLSET`), origen (`DECLARED`/`DISCOVERED`), cantidad de
      URLs, última lectura, último envío exitoso, resultado del último envío, último error
- [ ] Acción “Sincronizar ahora” con el resultado como lote enlazado (RT-13)
- [ ] `C-12 EmptyState` que explica qué es un sitemap para quien no lo sabe

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Vacío | Sin sitemaps | “Todavía no registraste ningún sitemap.” + qué es + dónde suele estar + botón de alta |
| Leyendo | Tras registrar o sincronizar | La fila entra con estado “Leyendo…”; el resto de la tabla sigue usable |
| Éxito | Lectura y envío correctos | Fecha del último envío exitoso y cantidad de URLs |
| Sin cambios | `SKIPPED_UNCHANGED` | Tratamiento **neutro y positivo**, no de error: “Sin cambios desde el último envío” |
| Error (`422`) | Tres casos distintos | Tres mensajes distintos, en la fila y no en un toast |
| Parcial | El lote de sincronización quedó `PARTIAL` | Enlace al lote con el motivo, y qué sitemaps quedaron sin procesar |

**Jerarquía**: la columna de resultado del último envío junto a su fecha. Es la pregunta que trae
al usuario a esta vista: “¿Google tiene lo último que publiqué?”.

**Microcopy clave**

- Estado vacío: “Todavía no registraste ningún sitemap. Un sitemap es un archivo del sitio que
  lista sus URLs; suele estar en `https://ejemplo.com/sitemap.xml` o
  `https://ejemplo.com/sitemap_index.xml`. Si registrás un índice, descubrimos solos los archivos
  que declara.”
- Campo de alta: “Dirección del sitemap” · ejemplo `https://ejemplo.com/sitemap_index.xml`
- Resultados del envío:
  - `OK`: “Enviado el 15 ago 2026”
  - `SKIPPED_UNCHANGED`: “Sin cambios desde el último envío”
  - `FAILED`: “Falló el 15 ago 2026” + motivo en la fila
- Aclaración fija bajo la tabla (RT-05): “Enviar un sitemap le avisa a Google que existe y qué
  cambió. No decide si lo rastrea ni cuándo.”
- Los tres errores de `422`:
  - Inaccesible: “No pudimos descargar `https://ejemplo.com/sitemap.xml`: el servidor respondió
    404. Revisá la dirección o que el archivo esté publicado.”
  - Mal formado: “El archivo se descargó pero no es un sitemap válido: el XML no tiene la etiqueta
    `<urlset>` ni `<sitemapindex>`.”
  - URLs ajenas: “El sitemap declara 12 URLs que no pertenecen a `ejemplo.com`. Las ignoramos.
    Revisá que sea el sitemap de este dominio.”

**Accesibilidad**

- [ ] La jerarquía índice → hijos se comunica con `aria-level` o con texto (“Declarado por
      `sitemap_index.xml`”), no sólo con sangría visual
- [ ] El resultado del envío es texto en su celda, con icono de refuerzo
- [ ] El error de una fila está asociado a esa fila (`aria-describedby`), no flotando arriba
- [ ] Las direcciones largas se pueden seleccionar y copiar enteras

**Trampas del dominio**

- Pintar “Sin cambios” con el mismo gris apagado que “Falló”: no enviar lo que no cambió es el
  comportamiento correcto y ahorra cuota; leerlo como falla empuja al usuario a forzar envíos
  inútiles (FR-026).
- Sugerir que enviar el sitemap equivale a que Google lo procese (RT-05).
- Mostrar `url_count` como “URLs indexadas”: son URLs **declaradas** en el archivo.
- Perder el motivo del error al recargar la página.

---

### V9 · `/domains/{id}/batches` — `Batches/Index`

**Propósito**: ver qué trabajo hizo la plataforma y, sobre todo, cuál quedó incompleto.

**Elementos obligatorios**

- [ ] `C-11 DataTable` con: tipo, origen, `C-07 BatchStateBadge`, progreso (`C-17`), fallidos, cupo
      consumido, inicio, fin, duración
- [ ] Distinción visual **estructural** entre `COMPLETED` y `PARTIAL`: icono distinto y una línea de
      motivo en la fila parcial
- [ ] Filtros por estado y por tipo, en la querystring
- [ ] Sondeo del estado mientras haya lotes en `QUEUED` o `RUNNING`, que se corta al terminar
      (RT-13)
- [ ] `C-12 EmptyState`

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Vacío | Nunca se ejecutó nada | “Todavía no se ejecutó ningún lote.” + cuándo corre el ciclo diario + “Inspeccionar ahora” |
| Cargando | Primera carga | Esqueleto de filas |
| En curso | Hay un lote `RUNNING` | Progreso real “412 de 2.000 procesadas”, sin barra indeterminada (RT-14) |
| Éxito | `COMPLETED` | Tilde, “Completado el …”, cifras finales |
| Parcial | `PARTIAL` | Icono propio, palabra “Parcial”, motivo y qué quedó pendiente |
| Fallido | `FAILED` | “No se procesó ninguna URL” + motivo + acción |

**Jerarquía**: el lote más reciente arriba, y dentro de la fila, el estado. Lo que el usuario viene
a chequear es si lo último que corrió terminó bien.

**Microcopy clave**

Etiquetas de los cinco estados:

| Estado | Etiqueta | Explicación de una línea |
|---|---|---|
| `QUEUED` | “En cola” | “Todavía no empezó.” |
| `RUNNING` | “En curso” | “412 de 2.000 procesadas.” |
| `COMPLETED` | “Completado” | “Se procesaron las 2.000 URLs del lote.” |
| `PARTIAL` | “Parcial” | “Se interrumpió antes de terminar. Quedaron 1.588 URLs sin inspeccionar; siguen en la cola del próximo ciclo.” |
| `FAILED` | “Falló” | “No se procesó ninguna URL.” |

- Tipos: “Sincronización de sitemaps” · “Inspección de URLs”
- Orígenes: “Programado” · “Manual” · “Desde la API”
- Estado vacío: “Todavía no se ejecutó ningún lote. El ciclo automático corre una vez por día por
  dominio.”

Prohibido: “Completado con advertencias”, “Completado (parcial)”, “Finalizado ✓” para un lote
`PARTIAL`.

**Accesibilidad**

- [ ] El progreso usa `role="progressbar"` con `aria-valuenow/min/max` **y** el texto “412 de
      2.000”
- [ ] La actualización por sondeo se anuncia con `aria-live="polite"` sólo cuando cambia el estado,
      no en cada tic
- [ ] `COMPLETED` y `PARTIAL` se distinguen en escala de grises (RT-04)

**Trampas del dominio**

- Usar la misma tilde para `COMPLETED` y `PARTIAL` cambiando sólo el color: es exactamente la
  confusión que prohíbe la restricción 3 y el invariante 4 del modelo.
- Mostrar 100 % de progreso en un lote `PARTIAL` porque “terminó”: si `processed < total`, la barra
  no llega al final.
- Presentar el cupo consumido como esfuerzo que promete resultado (RT-05).

---

### V10 · `/batches/{id}` — `Batches/Show`

**Propósito**: entender qué hizo exactamente un lote, cuánto cupo gastó y por qué terminó como
terminó.

**Elementos obligatorios**

- [ ] Encabezado con `C-07 BatchStateBadge` y **el motivo en una frase**, no sólo el estado
- [ ] Cifras: total, procesadas, fallidas, cupo consumido, inicio, fin, duración
- [ ] `C-17 BatchProgress` coherente con las cifras
- [ ] Resumen (`summary`) presentado según el tipo:
      - `SITEMAP_SYNC`: enviados, omitidos por no haber cambiado, fallidos con su motivo
      - `URL_INSPECTION`: conteo por estado obtenido, con la fecha de obtención del lote, y cambios
        de estado detectados
- [ ] Lista de ítems fallidos con su motivo individual
- [ ] Enlaces: al dominio, a la cobertura filtrada por lo que produjo este lote, al lote siguiente o
      anterior si existe
- [ ] `C-10 QuotaMeter` del día del lote, para situar el consumo

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| En cola | `QUEUED` | “En cola desde …”, sin cifras inventadas, sin barra en movimiento |
| En curso | `RUNNING` | Progreso real, sondeo activo, cifras que crecen |
| Completado | `COMPLETED` | Resumen completo y fechado |
| Parcial | `PARTIAL` | Motivo explícito arriba de todo y qué quedó pendiente |
| Fallido | `FAILED` | Motivo, ningún ítem procesado, acción concreta |
| No encontrado | `404` | “No existe o no está en tu cuenta.” sin confirmar que exista en otra |

**Jerarquía**: el estado terminal **y su motivo**, en una sola frase, antes que cualquier número.
“Parcial: se agotó el cupo del día” responde la pregunta completa; el resto son detalles.

**Microcopy clave**

- `PARTIAL` por cuota: “Parcial — se agotó el cupo diario de este dominio. Se inspeccionaron 412 de
  2.000 URLs. Las 1.588 restantes siguen en la cola y entran en el ciclo de mañana.”
- `PARTIAL` por límite de Google: “Parcial — Google respondió que se alcanzó su límite de consultas.
  Reencolamos el resto con espera creciente.”
- `FAILED`: “Falló — no se procesó ninguna URL porque el dominio dejó de ser accesible el 15 ago
  2026.” + “Comprobar el acceso”
- Resumen de sincronización: “Se enviaron 2 sitemaps. Se omitieron 5 porque no cambiaron desde el
  último envío. 1 falló.”
- Resumen de inspección: “Estados obtenidos el 17 ago 2026: 318 indexadas, 74 rastreadas sin
  indexar, 20 descubiertas sin rastrear.” — siempre con la fecha (R-A).
- Cambios detectados: “12 URLs pasaron de indexadas a no indexadas.” + enlace a esas URLs.

**Accesibilidad**

- [ ] El motivo del estado terminal está en el mismo bloque que el badge, no al pie
- [ ] La lista de fallidos es una tabla con encabezados, no un párrafo con comas
- [ ] Mientras se sondea, `aria-live="polite"` sólo sobre el bloque de cifras

**Trampas del dominio**

- Decir que las URLs del lote “se enviaron para indexar”: no existe envío a indexación en este
  producto y afirmarlo viola el principio I.
- Mostrar el conteo por estado sin la fecha del lote (R-A).
- Redondear un `PARTIAL` a `COMPLETED` cuando faltó poco.
- Presentar `quota_consumed` sin decir que es cupo del dueño del sitio, no un recurso nuestro
  (principio II).

---

### V11 · `/api-keys` — `ApiKeys`

**Propósito**: crear la clave con la que el pipeline llama a la API y poder revocarla sin dudar.

**Elementos obligatorios**

- [ ] Lista de claves vigentes con: nombre, prefijo, fecha de creación, último uso (`C-08`, “Nunca
      se usó” cuando es nulo), acción “Revocar”
- [ ] **Sección separada de claves revocadas** (FR-061), debajo de las vigentes, con su fecha de
      revocación (`revoked_at`) y su último uso, **sin ninguna acción** en la fila
- [ ] Las revocadas no se mezclan con las vigentes ni se ocultan detrás de un filtro apagado por
      defecto: se ven al llegar, porque son el dato que se busca después de un incidente
- [ ] Formulario de creación con un campo (“Nombre”) y su para qué
- [ ] **Panel de revelación única** tras crear: valor completo en `C-09`, aviso de que no se vuelve
      a mostrar, y el panel se cierra sólo con una acción explícita del usuario
- [ ] Confirmación destructiva para revocar (`C-14`), que nombra la clave y su consecuencia
- [ ] Enlace a la documentación de la API y al ejemplo de integración a un pipeline
- [ ] `C-12 EmptyState`

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Vacío | Sin claves | “Todavía no creaste ninguna clave.” + para qué sirve + botón de creación |
| Creando | Envío del formulario | Botón en “Creando…” |
| Recién creada | Respuesta `201` | Panel con el valor completo, botón de copiar y aviso de irrepetibilidad. **No** es un toast |
| Copiada | Tras copiar | Confirmación junto al botón, anunciada a lector de pantalla |
| Con claves | Normal | Lista de vigentes con su último uso |
| Revocando | En el diálogo | Botón en espera dentro del diálogo |
| Recién revocada | Tras confirmar | La fila se mueve a la sección de revocadas con su fecha, y se confirma el cambio: la clave no desaparece de la pantalla |
| Sólo revocadas | Todas las claves están revocadas | Sección de revocadas visible más el estado vacío de las vigentes con el botón de creación |

**Jerarquía**: cuando acaba de crearse una clave, **el valor completo** manda sobre todo lo demás
en la pantalla: es la única vez que se puede ver. El resto del tiempo, la lista.

**Microcopy clave**

- Estado vacío: “Todavía no creaste ninguna clave. Las claves sirven para que tu pipeline de
  despliegue le avise a la plataforma que el sitio cambió, sin entrar acá.”
- Campo nombre: “Nombre” · ayuda: “Para reconocerla después. Por ejemplo: `deploy-produccion`.”
- Panel de revelación: “Esta es tu clave. Guardala ahora: por seguridad no vamos a poder volver a
  mostrártela. Si la perdés, creá una nueva y revocá esta.”
- Botón de cierre del panel: “Ya la guardé”
- Último uso: “Nunca se usó” / “Último uso: hace 2 horas”
- Confirmación de revocación: “Revocar «deploy-produccion». Cualquier pipeline que use esta clave
  va a empezar a recibir un error de autenticación. Esta acción no se puede deshacer.” · Botón:
  “Revocar la clave”
- Encabezado de la sección de revocadas: “Claves revocadas”
- Explicación de la sección: “Las dejamos listadas para que puedas revisar qué clave estuvo
  vigente, hasta cuándo y cuándo se usó por última vez. Una clave revocada no se puede reactivar.”
- Fila revocada: “Revocada el 15 ago 2026 · último uso: 14 ago 2026”
- Tras revocar: “Revocamos «deploy-produccion». Queda listada abajo con su fecha.”

**Accesibilidad**

- [ ] El panel de revelación recibe el foco al aparecer y se anuncia con `role="alertdialog"` o
      región viva asertiva
- [ ] El valor de la clave es texto seleccionable en fuente monoespaciada, no una imagen ni un campo
      deshabilitado
- [ ] El botón de copiar anuncia el resultado (“Clave copiada”) por región viva
- [ ] El diálogo de revocación cumple RT-10: foco inicial en Cancelar, sin cierre por clic afuera

**Trampas del dominio**

- Mostrar la clave completa en un mensaje efímero que se cierra solo: el dato es irrepetible
  (RT-11).
- Hacer desaparecer una clave revocada de la pantalla: después de un incidente, lo que se busca es
  justamente qué clave existió y hasta cuándo (FR-061).
- Dejar el botón “Revocar” en una fila ya revocada, aunque sea deshabilitado: no hay acción posible
  ahí (RT-07).
- Dejar el valor de la clave en las props de la página, de modo que recargar lo vuelva a mostrar:
  el contrato dice que se devuelve una única vez (FR-041, RT-06).
- Revocar sin diálogo, o con un diálogo que dice “¿Estás seguro?” sin nombrar la clave ni la
  consecuencia.
- Mencionar límites de uso o cuotas por plan asociadas a las claves (RT-07).

---

### V12 · `/sessions` — `Sessions`

**Propósito**: ver dónde está abierta la propia sesión y poder cerrarla ante una sospecha, sin
quedarse afuera por accidente.

**Elementos obligatorios**

- [ ] Lista de sesiones activas con los cuatro datos que permiten decidir (FR-059): navegador y
      sistema derivados del `user_agent`, dirección de origen (`ip_address`), última actividad
      (`last_activity_at`, con `C-08`) e inicio (`created_at`)
- [ ] Marca inequívoca **“Esta sesión”** en la fila con `is_current`, tomada del servidor y no
      adivinada en el navegador
- [ ] Orden por última actividad, de la más reciente a la más vieja
- [ ] Acción “Cerrar” por sesión, con confirmación; ausente en la fila `is_current`
- [ ] Acción “Cerrar todas las demás” (`DELETE /sessions`), que **conserva** la sesión actual, con
      confirmación
- [ ] Explicación del efecto: hay que volver a ingresar en esos dispositivos
- [ ] Cuando sólo hay una sesión, se dice explícitamente

**Estados a cubrir**

| Estado | Cuándo | Qué muestra |
|---|---|---|
| Sólo la actual | Una sesión | “Sólo tenés esta sesión abierta.” sin acciones destructivas ofrecidas |
| Varias | Más de una | Lista con la actual destacada y las acciones disponibles |
| Dato incompleto | `user_agent` vacío o irreconocible | “Navegador no identificado” + la dirección y la última actividad, que siguen sirviendo para decidir. Nunca se inventa un nombre de dispositivo |
| Cerrando | Durante la acción | Fila en espera; el resto de la lista sigue usable |
| Después de cerrar las demás | Éxito | Queda una sola fila y un mensaje que confirma cuántas se cerraron |

**Jerarquía**: la identificación de **cuál es la sesión actual**. Sin eso, la acción principal de la
vista es peligrosa.

**Microcopy clave**

- Encabezado: “Sesiones abiertas”
- Marca: “Esta sesión” (badge, no sólo negrita)
- Fila: “Chrome en Windows · 190.12.44.7 · última actividad hace 4 minutos · abierta el 12 ago
  2026”
- Encabezado de la columna de origen: “Dirección de origen” — nunca “Ubicación”
- Navegador desconocido: “Navegador no identificado”
- Una sola sesión: “Sólo tenés esta sesión abierta.”
- Confirmación individual: “Cerrar esta sesión. En ese dispositivo va a hacer falta ingresar de
  nuevo.”
- Confirmación masiva: “Cerrar las otras 3 sesiones. Vas a seguir con la sesión actual abierta; en
  los demás dispositivos va a hacer falta ingresar de nuevo.”
- Resultado: “Cerramos 3 sesiones.”

**Accesibilidad**

- [ ] “Esta sesión” se comunica con texto, no sólo con un color de fila
- [ ] Cada botón “Cerrar” tiene nombre accesible único (“Cerrar la sesión iniciada el 12 ago”), no
      tres botones llamados “Cerrar”
- [ ] El resultado se anuncia en región viva y la lista se actualiza sin recargar toda la página
- [ ] Los diálogos cumplen RT-10

**Trampas del dominio**

- Ofrecer “Cerrar todas” incluyendo la propia sin avisar que va a expulsar al usuario.
- Traducir la dirección de origen a una ciudad y presentarla como dato cierto: la geolocalización
  por IP es una inferencia, y el principio I también aplica a los datos que mostramos de nosotros
  mismos. Se muestra la dirección tal cual, con el encabezado “Dirección de origen”.
- Ofrecer “Cerrar” en la fila `is_current`: la marca existe justamente para no invitar a cerrarla
  por error (FR-059).
- Confiar en el navegador para decidir cuál es la sesión actual: `is_current` lo dice el servidor.
- Mostrar la sesión con un identificador técnico crudo que no ayuda a decidir nada.

---

## 4. Mapa de vistas a tareas de implementación

Al construir una vista, estas son las tareas de [tasks.md](./tasks.md) que hay que tener cerradas
para poder marcar todas sus casillas.

Tabla reconstruida contra la numeración vigente de `tasks.md` (136 tareas).

| # | Vista | Tareas de backend / contenido | Tareas de interfaz | Tareas de verificación |
|---|---|---|---|---|
| V1 | `Login` | T019, T021 | T019, T039 | — |
| V2 | `Settings/Index` | T045, T047, T048, T049, T050, T051, T052, T053, T037 | T054, T055, T056, T039 | T040, T041, T042, T043, T044, T046, T129, T131 |
| V3 | `Onboarding/Wizard` | T052, T053, T100, T101, T102, T103, T106 | T104, T105, T055, T107 | T096, T097, T098, T099, T131 |
| V4 | `Domains/Index` | T074, T057, T079, T037, T115 | T082, T039 | T058, T061 |
| V5 | `Domains/Create` | T069, T074, T057 | T082 | T058, T061 |
| V6 | `Domains/Show` | T070, T074, T076, T078, T116, T122, T037 | T082, T038, T039 | T059, T061 |
| V7 | `Coverage/Index` | T065, T072, T075, T077, T080, T081, T134 | T082, T038 | T060, T062, T066, T110, T131 |
| V8 | `Sitemaps/Index` | T087, T088, T089, T090, T091, T092 | T093, T038 | T084, T085, T086 |
| V9 | `Batches/Index` | T073, T117 | T117, T038, T039 | T108, T130 |
| V10 | `Batches/Show` | T073, T090, T117 | T117, T038 | T108, T110, T131 |
| V11 | `ApiKeys` | T016, T017, T094, T095 | T094 | T018 |
| V12 | `Sessions` | T123, T124, T125 | T125 | T119 |
| — | Reglas y componentes transversales | T008, T021, T022, T037 | T010, T038, T039 | T127, T129, T131, T135 |

**Dónde caen las seis tareas nuevas**

| Tarea | Qué habilita | Vistas |
|---|---|---|
| T037 — estado de cuenta y `GET /account` | `C-18 AccountNotice`, `can_operate`, `display_timezone` | Transversal (RT-01, RT-18); citada en V2, V4 y V6 |
| T038 — mecanismo único de actualización | RT-13: consulta cada 5 s, corte en estado terminal o pestaña oculta | V6, V7, V8, V9, V10 |
| T078 — `PATCH /domains/{id}` | Bloque de configuración editable del dominio | V6 |
| T079 — resumen de cobertura del listado | Columna “Cobertura (con dato / total)” con su fecha | V4 |
| T080 — historial de una URL | Panel lateral de la línea de tiempo dentro de la vista | V7 |
| T124 — modelo `Session` | Navegador, dirección de origen y última actividad por sesión | V12 |

**Tareas que ninguna vista puede saltear**: T039 (armazón compartido: disposición, navegación,
formularios, estados de carga y presentación de errores), T021 (errores estructurados y mensajes
efímeros), T037 (estado de cuenta, del que salen el aviso permanente y la zona de presentación),
T038 (único mecanismo de actualización) y T008 (declaración de la zona horaria). Son la base de
`C-01`, `C-12`, `C-13`, `C-18` y de RT-01, RT-08, RT-11, RT-12, RT-13 y RT-18.

---

## 5. Trazabilidad de los huecos detectados

La primera versión de este documento levantó diez huecos. Nueve se aceptaron y ya son requisitos
del spec; el décimo sigue abierto por decisión de alcance. Esta tabla existe para poder verificar,
al construir cada vista, que lo que el hueco pedía efectivamente está.

| Hallazgo | Qué faltaba | Requisito que lo resolvió | Contrato / modelo | Dónde se aplica |
|---|---|---|---|---|
| H-02 | No había forma de editar la configuración de un dominio | **FR-057** | `PATCH /domains/{domain_id}` con `notifications_enabled`, `daily_inspection_budget` y `manual_reserve` | V6 — bloque de configuración editable, con validación de reserva contra presupuesto |
| H-03 | La exportación de cobertura no tenía contrato ni comportamiento por volumen | **FR-058** | `GET /domains/{domain_id}/coverage/export` — `200` con el archivo o `202` con lote | V7 — la pantalla anuncia cuál de los dos va a ocurrir antes de confirmar |
| H-04 | Las sesiones no tenían datos para decidir cuál cerrar | **FR-059** | Entidad `Session`; `GET /sessions`, `DELETE /sessions`, `DELETE /sessions/{id}`; `is_current` | V12 — navegador, dirección de origen, última actividad e inicio; sin acción en la fila actual |
| H-05 | El historial por URL se guardaba pero no se podía ver | **FR-060** | `GET /domains/{domain_id}/coverage/history?url=…` | V7 — panel lateral con la línea de tiempo, sin salir de la vista ni perder el filtro |
| H-06 | Las claves revocadas desaparecían de la pantalla | **FR-061** | `revoked_at` en el esquema `ApiKey` | V11 — sección aparte de claves revocadas, sin acciones |
| H-07 | El listado de dominios no permitía decidir a cuál entrar | **FR-062** | `coverage_summary` con `total_urls`, `known_urls`, `indexed`, `not_indexed` y `last_cycle_at` | V4 — columna con el denominador delante de los conteos |
| H-08 | No estaba definido cómo se actualiza el trabajo en curso | **FR-063** | Mecanismo único: cada 5 s mientras haya lote no terminal y la pestaña esté visible | RT-13 y un solo hook compartido; V6, V7, V8, V9, V10 |
| H-09 | No había lugar para los avisos de nivel cuenta | **FR-064** | `GET /account` con `can_operate`, `domains_with_lost_access` y `notices[]` | RT-18 y `C-18 AccountNotice`, en toda vista dentro de `C-01` |
| H-10 | Las fechas se mostraban sin zona declarada | **FR-065** | `display_timezone` en `GET /account`; la misma zona corta el día del cupo | RT-01 — formateo en esa zona y leyenda al pie de toda vista con datos fechados |

### Hueco abierto

**H-01 · No hay recuperación de contraseña.**
Queda fuera de alcance del primer corte: las cuentas se crean y se recuperan desde el panel
interno. La consecuencia de diseño sigue vigente y es verificable: **V1 no puede ofrecer el enlace
“¿Olvidaste tu contraseña?”**, porque llevaría a una capacidad que no existe (RT-07, principio VI).
Si alguna vez se enciende, se enciende con su pantalla completa, no con un enlace anticipado.

## 6. Verificación final de la interfaz

Antes de dar por cerrada cualquier vista:

- [ ] Ningún estado de indexación aparece sin su fecha de obtención, ni en pantalla ni en la
      exportación (R-A, SC-005)
- [ ] `UNKNOWN` se lee como “todavía no consultamos”, está fuera de todo porcentaje y no comparte
      familia visual con ningún estado negativo (R-B)
- [ ] `PARTIAL` y `COMPLETED` se distinguen sin color y sin leer las cifras (R-C)
- [ ] Ningún texto promete indexación, posicionamiento ni velocidad de rastreo (R-D, T131)
- [ ] El material de la clave no aparece en ninguna pantalla ni en las props que viajan al
      navegador (R-E, T044)
- [ ] Los cinco errores de credencial tienen cinco textos y cinco acciones distintas (SC-002)
- [ ] Ninguna pantalla menciona planes, precios, límites por plan ni verificación por TXT (T129)
- [ ] Ninguna vista sugiere que una cobertura parcial es total
- [ ] Toda fecha se presenta en la zona declarada por `display_timezone`, y esa zona está escrita en
      la pantalla (RT-01, FR-065)
- [ ] Con `can_operate` en falso, ningún dominio se presenta como operativo y el aviso de cuenta
      está visible con su acción (RT-18, FR-064)
- [ ] Todo conteo de cobertura lleva su denominador, también en el listado de dominios (RT-03,
      FR-062)
- [ ] Las claves de API revocadas siguen listadas, con su fecha y sin acciones (FR-061)
- [ ] No hay ningún mecanismo de actualización propio de una vista: todas usan el único hook
      compartido (RT-13, FR-063)
