# Grupo E · El primer día — V1 Login, V3 Recorrido guiado, V2 Configuración

## V1 · Login — `/login` — [`frontend/pages/Login.tsx`](frontend/pages/Login.tsx)

**Veredicto**: ajustes finos — **severidad** 2/5
**El problema en una línea**: es la vista más sana del producto, pero el único error que alguien va a
ver de verdad —«el correo o la contraseña no coinciden»— se dibuja colgado del campo equivocado, y
quien llega expulsado de otra pantalla no se entera de por qué está acá.

### Qué pasa hoy

- La vista **no usa `AppLayout`** (`Login.tsx:33` monta su propio `min-h-screen`) y es la única que
  rinde su propio `<h1>` (`Login.tsx:38`, `text-2xl font-semibold`). Está bien que sea así —no hay
  cuenta todavía, así que no hay barra lateral, ni `SiteHeader`, ni `AccountNotices`— pero choca de
  frente con la regla verificable 1 del contrato («ninguna página escribe `<h1>`»).
- El error de credenciales llega del servidor como `errors.password` (`apps/web/views.py:71`) y se
  rinde con `FieldError` **debajo del campo de contraseña**. El mensaje dice «El correo **o** la
  contraseña no coinciden», así que marca `aria-invalid` en un campo cuando el equivocado puede ser
  el otro. El `ux-checklist` V1 pide explícitamente un «contenedor de error a nivel de formulario,
  por encima de los campos», y no existe.
- El foco no se mueve al error. `FieldError` lleva `role="alert"`, así que se anuncia, pero quien
  navega con teclado queda parado donde estaba y tiene que bajar a buscarlo.
- El estado **«sesión expirada»** del checklist no está implementado. El servidor ya manda el dato
  que hace falta —la prop `next` (`apps/web/views.py:45`)— y la pantalla lo usa sólo como campo
  oculto (`Login.tsx:23`). Alguien redirigido desde una vista protegida ve un formulario pelado.
- Tres escalas de espaciado en 81 líneas: `space-y-8` (32 px, fuera de los cinco valores del
  contrato), `space-y-4` y `space-y-2`.
- `login-form.tsx` está instalado y sin consumir. En sus 105 líneas hay **seis cosas prohibidas**:
  copia en inglés («Welcome back», «Login to your Acme Inc account», `login-form.tsx:24-27`), tres
  botones de ingreso social Apple / Google / Meta para métodos que no existen (`:56-84`, RT-07),
  «Forgot your password?» apuntando a `href="#"` (`:41-46`, el hueco H-01 sigue abierto), «Sign up»
  (`:86`), Términos y Privacidad a `#` (`:100-101`) y un `/placeholder.svg` (`:92`).

### En qué orden debería mirarse

1. Si hay algo que explicar sobre por qué estoy acá (sesión expirada, o el error del intento
   anterior). Si no hay nada, este renglón no existe.
2. Qué es esto: el nombre del producto y una línea de qué hace.
3. El campo de correo, con el foco puesto.
4. El botón.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `<h1>` + subtítulo (`:37-42`) | Se queda, arriba de todo | Bloque propio de la vista | Es la única pantalla donde el producto se nombra a sí mismo. El subtítulo actual ya es RT-05 limpio: describe («sincronización de sitemaps y monitoreo de cobertura»), no promete |
| `errors.password` con el mensaje de credenciales | Contenedor de formulario **arriba** de los campos, `role="alert"`, foco al recibirlo, los dos campos en `aria-invalid` | `Alert` + `FieldError` a nivel `FieldGroup` | El mensaje habla de dos campos; pinchado a uno acusa al que puede estar bien. Requiere que el servidor lo mande como `errors.form` en vez de `errors.password` |
| Nada (hoy) + prop `next` ya existente | Renglón neutro arriba del título cuando `next` viene con valor | `Alert` `variant="default"` | Cero información nueva: el dato ya viaja. Lo que se decide es **cuándo aparece** |
| `Label` + `Input` + `FieldError` a mano, ×2 (`:45-72`) | La primitiva `field` | `Field` / `FieldLabel` / `FieldDescription` / `FieldError` | Es la primitiva más desaprovechada del repositorio (contrato §3.10) y este formulario es el más chico donde estrenarla |
| `space-y-8` / `space-y-4` / `space-y-2` | 24 / 16 / 8 | Escala del contrato §2 | El 32 px no pertenece a ninguna escala |
| `login-form.tsx` entero | **Se borra el archivo** | — | Seis prohibiciones en 105 líneas. Lo único que vale la pena es su lista de importaciones, y eso se adopta sin el archivo |

**Veredicto sobre `login-form.tsx`: se descarta el archivo y se adopta la primitiva.** No es lo
mismo: lo que hay que usar es `@/components/ui/field`, que el bloque demuestra cómo se compone. El
bloque en sí es la demo de `dashboard-01` con la marca de otro producto, y adoptarlo tal cual mete
en la primera pantalla del producto tres proveedores de identidad inexistentes y dos enlaces a `#`.

Además: `type="email"` ya está; falta `inputMode="email"`. Y el campo de contraseña no bloquea el
pegado, que es correcto y hay que dejarlo así.

### Componentes compartidos que necesita

- Ninguno propio. Consume `Field`/`FieldGroup`/`FieldLabel`/`FieldError` de la primitiva `field`, ya
  instalada. Si el `Alert` de formulario se repite en otra vista sin `AppLayout` —no hay otra hoy—
  recién ahí se nombra.

### Qué no tocar

- Que **no** use `AppLayout`. Es correcto y el `ux-checklist` V1 lo dice: es la única vista sin aviso
  de nivel cuenta y sin zona de presentación, porque todavía no hay cuenta.
- El mensaje ambiguo de credenciales. Es la única excepción deliberada del producto a «errores
  específicos», y su motivo está escrito en el docstring (`Login.tsx:13-15`): separar «ese correo no
  existe» de «la contraseña no es ésa» convierte el formulario en un enumerador de correos.
- `form.reset('password')` en `onFinish` (`:29`). La contraseña no sobrevive a la navegación.
- El reenvío de `next` como campo del formulario (`:19-23`), con su comentario. El POST va a
  `/login` a secas y por la cadena de consulta el destino se perdería.

### Reglas en juego

- **RT-08**: el error de credenciales pasa a ser de formulario y no de campo, arriba y no en un
  toast. Los errores de campo vacío (`Escribí tu correo.`) siguen junto a su campo.
- **RT-07**: nada de «Crear cuenta», «¿Olvidaste tu contraseña?» ni proveedores sociales. Es la razón
  por la que `login-form.tsx` se borra en vez de adaptarse.
- **RT-05**: el subtítulo actual no promete indexación. Cualquier reescritura tiene que seguir
  describiendo la mecánica, no el resultado.
- **RT-15**: `autoFocus` en el correo ya está; el foco al error de formulario es lo que falta.
- Contrato, regla verificable 1: hay que abrirle una excepción escrita a esta vista.

---

## V3 · Recorrido guiado — `/onboarding` — [`frontend/pages/Onboarding/Wizard.tsx`](frontend/pages/Onboarding/Wizard.tsx)

**Veredicto**: reestructurar — **severidad** 3/5
**El problema en una línea**: el contenido y la máquina de estados son lo mejor que tiene el
producto, y la pantalla los dibuja dos veces —una en el panel abierto y otra en la lista de abajo—
sin que nada de eso llegue a la URL.

### Qué pasa hoy

- **Cada paso se rinde dos veces en la misma pantalla.** `StepPanel` (`Wizard.tsx:392`) dibuja
  título + estado + fecha del paso abierto, y `StepProgress` (`Wizard.tsx:241`) vuelve a dibujar
  título + estado + fecha de los siete, incluido el que ya está arriba. En la captura, «Correr la
  primera sincronización» aparece completo a 1368 px de scroll **y** otra vez como fila 7. De los 11
  botones de la vista, 7 son las filas de esa lista.
- **Nada del recorrido está en la dirección.** El paso abierto vive en `useState`
  (`Wizard.tsx:145`), así que Atrás no vuelve al paso anterior, no se puede enlazar «mirá el paso
  4», y `preserveState: true` en el `verify` (`:426`) existe justamente para tapar ese agujero.
- **Es una página con marco completo, no un tratamiento aparte.** Usa `AppLayout` (`:199`), o sea
  barra lateral, `SiteHeader` y `AccountNotices`. El slot `actions` del encabezado está sin usar.
- **El aviso de cuenta contradice a la pantalla en la primera visita.** La raíz redirige a
  `/onboarding` mientras `should_offer` sea verdadero (`apps/web/views.py:127`), y `AppLayout` rinde
  `AccountNotices` siempre (`AppLayout.tsx:68`). Sin credencial, eso es el aviso `CREDENTIAL_MISSING`
  —«Falta conectar Google», botón **«Conectar Google» → `/settings`**
  (`apps/accounts/services.py:104-115`)— clavado arriba de un recorrido cuyo paso 3 es exactamente
  eso. La primera pantalla del producto empuja a la otra página antes de que la persona haya hecho
  nada.
- **Un `h2` a `text-lg`** en el título del paso (`:443`) y otro en el cierre (`:1145`): 18 px, que el
  contrato §1 prohíbe. Y `StepProgress.tsx:59` pone un `h2` a `text-sm` —T4 sobre un `h2`—, o sea el
  mismo nivel dibujado a dos tamaños en la misma pantalla.
- **Siete `rounded-lg border` a mano** en el archivo (`BlockedStep`, los tres `CheckResult`,
  `ServiceAccountAddress`, `ReachedProperties`, las opciones de radio), todos con `p-4` o `p-3`
  propio adentro de una `Card` que ya define `--card-spacing`.

### En qué orden debería mirarse

1. Cuánto falta: en qué paso estoy de siete y cuántos ya están cumplidos.
2. Qué se consigue con el paso abierto —el objetivo en una frase, antes que la ruta—.
3. Dónde se hace y qué dato traer.
4. El botón que comprueba, y sólo después el veredicto.
5. La salida: «prefiero hacerlo desde el formulario completo» y «omitir el recorrido».

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `StepPanel` + `StepProgress` (dos dibujos del mismo paso) | Una sola estructura: `Accordion type="single" collapsible` con `?step=` en la URL | `accordion` (⛔ instalar) | Árbol §4 regla 6: 3+ bloques secundarios del mismo tipo. La fila cerrada del acordeón **es** la fila de hoy de `StepProgress`; el panel abierto **es** el `StepPanel`. Un solo título por paso, y el estado de revelación en la URL como manda §4 |
| `GuideStep` (usado sólo por V2) | Se borra; su anatomía queda en el ítem abierto del acordeón | — | Objetivo · Dónde · Qué traer · Ejemplo, los mismos cuatro bloques del `StepPanel`, con dos marcados distintos. El docstring de `StepPanel` (`:377-391`) justifica no reutilizarlo por el `<li>` y por el encabezado enfocable: el acordeón resuelve las dos cosas |
| Cabecera «Paso N de 7» + título + objetivo (`:437-450`) | `AccordionTrigger` (número + título + `StatusBadge` + fecha) y `AccordionContent` (objetivo primero) | `accordion` + `StatusBadge` | El orden «objetivo antes que ruta» es la decisión de fondo de la vista y se conserva literal |
| `PENDING_STATES` de `StepProgress` (`:36-40`) | `StatusBadge` con los cuatro tonos | §3.7 | `PASSED`→`positive` · `MISSING`→`attention` · `BLOCKED`→`neutral` (`Lock`) · `UNCONFIRMED`→`unknown`, que es la familia RT-03 tal cual: borde punteado, sin relleno, «todavía no preguntamos» |
| `BlockedStep` (`:603`) | Contenido del ítem bloqueado, sin caja propia | `Item` + `StatusBadge tone="neutral"` | No es una falla; es la ausencia de un paso previo. El botón «Ir al paso que falta» pasa a ser un `<Link href="?step=…">` de verdad |
| `CheckResult` ×3 (`:656`) | Un bloque, con el tono del `StatusBadge` | `Item` + `CredentialErrorPanel` cuando el motivo es de Google | Tres cajas `rounded-lg border p-4` para tres variantes del mismo veredicto |
| `ServiceAccountAddress` (`:1063`) | `ServiceAccountAddress` compartido | §3.6 `Item` | Idéntico a `AuthorizeInSearchConsole` de V2 (`Settings/Index.tsx:871`) |
| `ReachedProperties` (`:1092`) + `PERMISSIONS` (`:1085`) | `AccessiblePropertyList` compartido | §3.6 | Idéntico a `AccessibleProperties` de V2 (`Settings/Index.tsx:601`), con el mismo mapa de permisos duplicado |
| `FileDropField` (`:890`) + `formatSize` (`:972`) | `KeyFileField` compartido | `field` + `input-group` | Idéntico al de V2 (`Settings/Index.tsx:762-813`, `:864`), hasta la misma frase «Guardamos la clave cifrada» |
| `GOOGLE_SCREENS` (`:113`) + `withProject` (`:137`) | `GoogleConsoleLink` compartido | Botón `asChild` | Tres tablas de las mismas URLs de Google: acá, en `Settings/Index.tsx:107-112` y en `CredentialErrorPanel.tsx:43` |
| `Completion` (`:1131`) | Se queda, como ítem extra del acordeón cerrado por default cuando `is_finished` | `Card` | Con el recorrido terminado, tres botones grandes arriba de siete filas ya cumplidas es una pantalla que festeja más de lo que informa |
| `SkipTour` (`:326`) | Se queda al pie, fuera del acordeón | Bloque propio | Su comentario ya explica por qué no va en el encabezado: la consecuencia son dos renglones y `actions` es de una línea |

**Las respuestas concretas que pedía el encargo:**

| Pregunta | Respuesta |
|---|---|
| Cuántos pasos | **Siete, sin agrupar.** No son pantallas: son los pasos que el servidor comprueba de a uno (`apps/onboarding/steps.py`, `SPECS`). Agruparlos haría que un paso fallara sin poder decir cuál |
| Qué se ve de un paso mientras estás en otro | Su número, su título, su palabra de estado con icono, y la fecha si está cumplido. **Una línea.** No su objetivo, no su ruta, no su ejemplo |
| Cómo se verifica cada uno | Igual que hoy: `POST onboarding.verify` por paso, un botón dentro del ítem abierto, con `Spinner` + «Comprobando…» (§3.9). Sin spinner de página. Se conserva que una sola consulta a Google conteste los pasos 2, 3 y 4 (`steps.py:364-381`) |
| Qué pasa si un paso falla | Los cuatro `CheckState` se conservan enteros. El veredicto va **dentro del ítem**, con `role="alert"` sólo si viene de una comprobación recién hecha —ya está resuelto en `justChecked` (`:371`)— y nunca en un toast. `BLOCKED` y `UNCONFIRMED` no se pintan como falla |
| Cómo se sale y se vuelve | Ya está resuelto en el servidor y no hay que tocarlo: `dismiss` no restringe nada, `OnboardingProgress` sobrevive, y `Context._saved_or_first` (`steps.py:181`) hace que retomar reencuentre el mismo dominio en vez de crear un segundo. Lo único que agrega el rediseño es `?step=` para que el botón Atrás funcione |

**Y la decisión de fondo: el recorrido sigue siendo página propia. Configuración deja de tener su
propia copia de los pasos.**

Recorriendo el árbol del contrato §4 en orden:

1. ¿Destructivo o irreversible? No.
2. ¿Tiene identidad propia, URL propia, o alguien llega desde afuera? **Sí, y por tres caminos**: la
   raíz redirige acá mientras el recorrido no esté terminado ni omitido (`apps/web/views.py:127`),
   tiene entrada de menú, y tiene rutas POST propias por paso. **Se para acá: página propia.**

El mismo árbol da «página propia» también para `/settings`. Que las dos sean páginas no es el
problema; el problema es que hoy **son la misma cosa dos veces y el menú obliga a elegir**:

- `apps/onboarding/content.py` es la **única** fuente de los siete pasos, y su docstring lo dice:
  «la interfaz y el recorrido guiado muestran los mismos pasos, y duplicarlos garantiza que en algún
  momento uno diga una ruta de menú que Google ya cambió y el otro no».
- `apps/credentials/views.py:39` recorta esa misma fuente a los cuatro primeros
  (`GUIDE_STEPS = ('GOOGLE_PROJECT', 'ENABLE_API', 'SERVICE_ACCOUNT_KEY', 'AUTHORIZE_PROPERTY')`) y
  V2 los dibuja adentro de un `<details>` con `GuideStep`.
- O sea: los pasos 1 a 4 están escritos una vez y **dibujados dos**, con dos marcados distintos, en
  dos páginas que el menú ofrece como hermanas bajo el grupo «Google».
- Y el propio servidor ya declaró cuál manda: el `form_path` por default de un paso es `/settings`
  (`steps.py:773`), o sea «la pantalla equivalente del modo directo». Los pasos 1 a 4 **son**
  Configuración, dichos de a uno.

Entonces:

- **`/onboarding` se queda**: los siete pasos, la primera corrida, de cero al primer lote encolado.
  Es el único lugar donde se explica de dónde sale cada dato.
- **`/settings` deja de dibujar los cuatro pasos.** El `<details>` «Cómo conseguir la credencial»
  desaparece; en su lugar, en el estado sin conectar, un botón «Guiame paso a paso» que lleva al
  recorrido en el paso que falta.
- **El menú pasa de dos entradas a una.** El grupo «Google» queda con **«Conexión con Google» →
  `/settings`**, y el recorrido se alcanza desde adentro. Esto está permitido explícitamente: el
  encabezado de `app-sidebar.tsx` autoriza cambiar entradas, rótulos y destinos, y prohíbe sólo la
  anatomía. El comentario que defiende dejar el recorrido en el menú
  (`app-sidebar.tsx:67-76`) sigue satisfecho —la pantalla sigue existiendo y sigue alcanzable para
  siempre—; lo que se saca es la **elección**, que hoy es entre dos rótulos que no dicen en qué se
  diferencian.

Lo que **no** hago es lo contrario: meter el recorrido adentro de Configuración como su estado
inicial. Dos motivos concretos. Los pasos 5, 6 y 7 no son de Configuración —dar de alta un dominio,
registrar un sitemap, correr la primera sincronización viven en Dominios, Sitemaps y Cobertura, y
sus `form_path` así lo dicen (`steps.py:838`, `:853`, `:859`)—; absorberlos haría que la pantalla de
la credencial fuera dueña del alta de dominios. Y el recorrido tiene comprobación **por paso** con
su propia ruta y su propia fila de progreso, mientras Configuración tiene una sola comprobación de
credencial: fusionarlos obliga a que una de las dos pierda su granularidad.

### Componentes compartidos que necesita

- **`StepList` / `StepItem`** — el acordeón de los siete pasos, con el estado en `?step=`.
  `{ steps: WizardStep[]; openStep: string; onOpenStep: (code: string) => void }` para la lista;
  el ítem recibe `{ step: WizardStep; children: ReactNode }` y rinde disparador (número + título +
  `StatusBadge` + `DataTimestamp`) y panel. Reemplaza `StepProgress.tsx` entero, el `StepPanel` de
  `Wizard.tsx:392` y `GuideStep.tsx`.
- **`ServiceAccountAddress`** — `{ clientEmail: string; heading?: string }`. La dirección con su
  `CopyButton` y la ruta exacta dentro de Search Console con el permiso en palabras. Reemplaza
  `Wizard.tsx:1063` y `Settings/Index.tsx:871`, que son el mismo `<section aria-labelledby="authorize"
  className="space-y-3 rounded-lg border p-4">` escrito dos veces.
- **`AccessiblePropertyList`** — `{ properties: Property[]; showWarning?: boolean }`. Dueño único del
  mapa `PERMISSIONS`, hoy duplicado en `Wizard.tsx:1085` y `Settings/Index.tsx:99`. Reemplaza
  `ReachedProperties` y `AccessibleProperties`.
- **`KeyFileField`** — `{ name: string; label: string; file: File | null; error?: string; onChange:
  (file: File | null) => void }`. La zona de carga con su `<input type="file">` visible, el arrastre
  como agregado `aria-hidden`, y nombre + tamaño del archivo elegido. Dueño único de `formatSize`.
  Reemplaza `FileDropField` (`Wizard.tsx:890`) y el bloque de `UploadForm` (`Settings/Index.tsx:762`).
  **Es el único lugar del producto que toca el archivo de clave**, así que RT-06 tiene un solo sitio
  donde comprobarse.
- **`GoogleConsoleLink`** — `{ screen: 'project' | 'enable-api' | 'service-accounts' | 'search-console';
  projectId?: string; children: string }`. Una sola tabla de URLs de Google. Reemplaza
  `GOOGLE_SCREENS` (`Wizard.tsx:113`), las tres constantes de `Settings/Index.tsx:107-112`, el
  `ExternalLinkButton` (`Settings/Index.tsx:1052`) y unifica los dos `withProject` idénticos
  (`Wizard.tsx:137` y `CredentialErrorPanel.tsx:43`).
- **`TaskColumn`** — `{ children }`, la columna angosta de las pantallas de tarea. Hoy son tres
  páginas con dos anchos distintos: `max-w-3xl` en Wizard y Settings, `max-w-2xl` en `Domains/Create.tsx:89`.

### Qué no tocar

- **La máquina de estados de `apps/onboarding/steps.py` entera.** Los cuatro `CheckState`, el reparto
  de los fallos de Google por paso (`API_NOT_ENABLED` al 2, `INVALID_KEY` al 3, `NO_PROPERTIES` al
  4), `blocked_by`, `evidence`, y que una sola consulta conteste los pasos 2, 3 y 4. Ahí está todo el
  conocimiento de dominio del producto.
- **`Emphasis`** (`GuideStep.tsx:32`). El contenido del servidor marca con `**…**` justo las palabras
  que se confunden. Si `GuideStep.tsx` se borra, `Emphasis` se muda, no se pierde.
- **Que «Continuar» no exista hasta que la comprobación pase** (`:565-575`), en vez de existir
  deshabilitado. Es RT-07 aplicado bien y está argumentado en el código.
- **Que el avance automático sólo ocurra si el paso pasó** (`:152-162`). Avanzar tras un fallo
  escondería el motivo y marcaría de hecho un paso que nadie comprobó.
- El slot `actions` del encabezado **sigue vacío**. La salida del recorrido necesita dos renglones de
  explicación y el encabezado es de una línea.
- `KeyFileExample` recibe **siempre** el ejemplo inventado del servidor
  (`apps/onboarding/content.py:21`), nunca el archivo de la persona.

### Reglas en juego

- **RT-03**: `UNCONFIRMED` es literalmente «todavía no preguntamos» aplicado a un paso. Le
  corresponde `StatusBadge tone="unknown"`: borde punteado, sin relleno, muted, reloj. Y no entra en
  el «7 de 7 cumplidos».
- **RT-04**: los cuatro estados con icono + palabra, en ese orden. Ya se cumple en
  `StepProgress.tsx:96-105` y hay que conservarlo al pasar al acordeón.
- **RT-05**: el cierre (`Completion`, `:1131`) es donde más fácil se rompe y hoy está bien: dice qué
  encolamos nosotros y qué se va a poder ver, con la fecha del dato de Google. No se toca ni una
  palabra.
- **RT-08**: el veredicto va dentro del paso, nunca en un toast; `ServerErrorNotice` para los códigos
  del servidor (`:208-222`) y `CredentialErrorPanel` para los de Google.
- **RT-11**: `preserveScroll` en el `verify` (`:422`) para que el resultado quede a la vista de quien
  apretó el botón.
- **RT-15**: ningún paso depende de arrastrar y soltar; el `<input type="file">` queda visible dentro
  de la zona y no escondido detrás de CSS.
- **FR-017 / FR-019**: cumplido lo dice la comprobación efectiva, y retomar no duplica.

---

## V2 · Configuración — `/settings` — [`frontend/pages/Settings/Index.tsx`](frontend/pages/Settings/Index.tsx)

**Veredicto**: rehacer — **severidad** 4/5
**El problema en una línea**: tres personas distintas —la que nunca conectó nada, la que ya está
funcionando y la que se le rompió— comparten una sola pantalla que además guarda una tercera copia
del recorrido guiado adentro de un `<details>`.

### Qué pasa hoy

- **Entra en 900 px porque dos acordeones esconden la mitad del DOM.** `v2-settings.png` y
  `fold-v2-settings.png` son la misma imagen: no hay scroll. Pero los 216 nodos y 103 bloques de
  texto medidos incluyen lo que está adentro de los tres `<details>` cerrados. La página se ve calma
  **sólo en el estado verificado**; el estado que la justifica —sin conectar— abre el formulario de
  carga *y* la guía de cuatro pasos a la vez (`Index.tsx:163-164`:
  `guideOpen = guideChoice ?? !verified`, `showUpload = uploadChoice ?? credential === null`).
- **Cinco `<h2>` escritos a mano, todos re-implementando `CardTitle`.** El archivo define
  `const HEADING = 'font-heading text-base leading-snug font-medium'` (`:81`) y lo usa cuatro veces
  adentro de `CardHeader`. Ese string es **carácter por carácter** lo que ya rinde `CardTitle`
  (`ui/card.tsx:41`). El quinto `<h2>` (`:372`) es `text-lg font-medium`: 18 px, prohibido por el
  contrato §1 y el único encabezado de la pantalla que se sale de escala.
- **El aviso de cuenta manda a la página en la que ya estás.** Con la credencial rota, `AppLayout`
  rinde `AccountNotices` arriba (`AppLayout.tsx:68`) con «La clave dejó de funcionar» y un botón
  **«Revisar la conexión» → `/settings`** (`apps/accounts/services.py:148-157`). Debajo, el
  `ConnectionStatusCard` dice «Hay un problema con la conexión» (`:491`). Debajo, el
  `CredentialErrorPanel` dice el motivo. **El mismo hecho, tres veces, y una de ellas es un botón que
  navega a donde ya estás.**
- **La guía es la tercera copia de los mismos pasos.** El servidor los escribe una vez
  (`apps/onboarding/content.py`), el recorrido los dibuja con `StepPanel`, y acá se dibujan otra vez
  con `GuideStep` recortados a cuatro (`apps/credentials/views.py:39`). Con el bloque abierto son
  ~500 px de guía **entre** el estado de la conexión y la ficha de la credencial.
- **El bloque que resuelve el estado más común queda enterrado.** `AuthorizeInSearchConsole` (`:871`)
  —la dirección de la cuenta de servicio con su `CopyButton` y la ruta exacta— es lo único que
  destraba `NO_PROPERTIES`, que es el estado **esperado** justo después de subir la clave. Vive al
  final de la guía, después de los cuatro pasos y del `KeyFileExample`.
- **Cuatro `rounded-lg border` con `p-*` propio** adentro de `Card`s que ya tienen `--card-spacing`
  (`:609`, `:873`, `:920`, más el `border-dashed p-4` de la zona de carga en `:777`).
- **Una tarjeta «Módulos» con una tabla de una fila.** El docstring (`:896-902`) la defiende como «la
  forma que va a tener la pantalla cuando haya dos». Hoy es una `Card` completa, con título y
  descripción, para decir un par etiqueta/valor que ya está dicho tres bloques más arriba.
- **`Card` dentro de `Card`** en `ModulesAndCredential`: la fila del módulo (`:920`) y el `<details>`
  de los datos (`:945`) son dos cajas con borde adentro de una tarjeta que ya tiene su anillo.

### En qué orden debería mirarse

1. Si la conexión funciona, y desde cuándo. Una frase con su fecha.
2. Si no funciona: **por qué** y **qué hacer**, en ese orden, con una sola acción.
3. Qué alcanza esa conexión: las propiedades, con su permiso.
4. Cuál es esta credencial: dirección, huella, proyecto. Sirve para identificar, no para decidir.
5. Lo peligroso: reemplazarla.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| Icono + `Badge` + fecha de `ConnectionStatusCard` (`:362-385`) | `PageIntro` inmediatamente bajo la `description` | §3.1 | El badge y la fecha son la línea de estado de la página, no el contenido de una tarjeta |
| `<h2 className="text-lg">` con el `headline` (`:372`) | `CardTitle` de la única tarjeta de estado | T3 | 18 px no existe en la escala; y el titular de la pantalla no compite con el `h1` del armazón |
| `CredentialErrorPanel` (`:394-402`) | Arriba de la `Section` que falló, primer bloque después de `PageIntro` | §3.10 | Es lo que hay que leer antes que nada cuando la conexión está caída |
| Guía `<details>` con los 4 `GuideStep` + 3 botones externos + `KeyFileExample` (`:243-296`) | **Se va a `/onboarding`** | — | Tercera copia de los mismos siete pasos del servidor. En su lugar, en el estado vacío, un botón «Guiame paso a paso» al paso que falta |
| `AuthorizeInSearchConsole` (`:871`) | Sube al cuerpo de la tarjeta de estado, **sólo** en `NEEDS_AUTHORIZATION` | `ServiceAccountAddress` compartido | Es la acción que destraba el estado más frecuente y hoy está al fondo de un acordeón |
| `UploadForm` sin credencial (`:639`) | `Section` en la página, abierta | Formulario en página | Árbol §4 regla 3: es el propósito de la pantalla en ese estado |
| `UploadForm` con credencial (reemplazo) | `Dialog` disparado desde `DangerZone`, y el `ConfirmDestructive` que ya existe detrás | `dialog` (⛔ instalar) + §3.11 | Tarea corta que bloquea, 2 campos. Hoy se despliega en el medio de la página y empuja todo |
| Tarjeta «Módulos» (`:903-1009`) | Un `LabelValue` «Se usa en: Search Console» dentro de la tarjeta de conexión | §3.6 | Una tabla de una fila no es una tabla. El día que haya dos módulos, es una `Section` con `DataTable` |
| `<details>` «Datos de la credencial» (`:945-997`) | `DescriptionList` visible en la tarjeta de conexión | §3.6 | Son cinco pares; el contrato manda al `Sheet` recién pasando los ocho. Plegar cinco pares es esconder la única forma de identificar la clave |
| `AccessibleProperties` (`:601`) | `Section` propia con `AccessiblePropertyList` | §3.6 `Item` | Deja de ser una lista con borde adentro de una tarjeta con anillo |
| Botón «Reemplazar la credencial» (`:999-1003`) | `DangerZone` al pie | §3.11 | La clave en uso deja de firmar consultas: es destructivo y hoy es un `variant="outline"` suelto |
| `CredentialHistory` `<details>` (`:1011`) | `Collapsible` al pie | §4 regla 6 | Un solo bloque secundario, no tres: es `Collapsible`, no `Accordion` |
| Nada (hoy `actions` está vacío) | «Volver a comprobar» en `actions` | Botón único, una línea | Es el único verbo repetible que existe en los tres estados. Sin badge, sin fecha, sin explicación |

**Los tres estados, y qué muestra cada uno.** Hoy son la misma página; el código ya distingue cinco
(`ConnectionState`, `:79`) y su docstring ya dice que «en cada uno manda una cosa distinta». Lo que
falta es que la pantalla lo haga.

| | **A · Sin conectar** (`EMPTY`) | **B · Conectada y funcionando** (`VERIFIED`) | **C · Caída** (`ERROR`, `UNCHECKED`, `NEEDS_AUTHORIZATION`) |
|---|---|---|---|
| `PageIntro` | `StatusBadge neutral` «Sin configurar» | `StatusBadge positive` «Comprobada» · fecha relativa · «3 propiedades» | `StatusBadge critical` (o `attention`) · «Última comprobación: …» |
| Primer bloque | Una `Card`: «Todavía no conectaste tu cuenta de Google», qué vas a necesitar, y **un** botón «Guiame paso a paso» | `Card` «Conexión»: `DescriptionList` con dirección, proyecto, huella, identificador de la clave y fecha de carga | `CredentialErrorPanel` con su título, su explicación y **una** acción, del mapa cerrado |
| Segundo bloque | «Ya tengo el archivo `.json`» revela el `UploadForm` en el lugar | `Section` «Propiedades que alcanza», cada una con su permiso | La `Card` «Conexión» con la misma ficha de identidad: huella y dirección son lo que hay que comparar contra Google mientras está roto |
| Qué **no** aparece | Módulos, historial, ficha técnica, propiedades: no hay nada que mostrar y una tarjeta vacía se lee igual que una rota | El formulario de carga, la guía, el aviso de cuenta | Las propiedades accesibles: son de la última comprobación que funcionó y leerlas ahí es leer un dato viejo como si fuera de hoy |
| Al pie | Nada | `Collapsible` «Credenciales anteriores» · `DangerZone` «Reemplazar la credencial» · `TimezoneFootnote` | Ídem B |
| Aviso de cuenta | **Suprimido en esta página** | — | **Suprimido en esta página** |

`NEEDS_AUTHORIZATION` es un caso C con tratamiento propio y **no se pinta de rojo**: el código ya lo
saca del grupo de los errores (`:124` y `CredentialErrorPanel.tsx:103`, `tone: 'step'`) porque la
clave funciona y lo que falta se hace en Search Console. Le corresponde `attention`, la frase «Tu
clave funciona. Falta autorizarla en Search Console», y `ServiceAccountAddress` promovido al cuerpo
de la tarjeta.

**Sobre RT-06, explícito**: lo que se muestra sigue siendo exactamente huella + dirección + proyecto
+ `private_key_id` + fecha. Nada del material de la clave, ni recortado, ni ofuscado, ni en un campo
`type="text"`. El servidor ya lo garantiza armando las props campo por campo
(`apps/credentials/views.py:137-167`, con su comentario: es la garantía de que `encrypted_key` no
puede salir aunque alguien agregue un campo al modelo). El rediseño **saca la huella del `<details>`
y la deja visible**, que es lo contrario de exponer un secreto: la huella existe justamente para
reconocer la clave sin verla, y esconderla obliga a buscar la identificación en otro lado.

### Componentes compartidos que necesita

Los cinco de V3 —`ServiceAccountAddress`, `AccessiblePropertyList`, `KeyFileField`,
`GoogleConsoleLink`, `TaskColumn`— más:

- **`ConnectionSummary`** — `{ state: ConnectionState; credential: Credential | null }`. La `Card`
  «Conexión»: `DescriptionList` de identidad + `LabelValue` del módulo. Reemplaza el `<details>`
  «Datos de la credencial» (`:945`) y la tarjeta «Módulos» entera (`:903`). Vive en V2 solamente,
  pero se nombra porque es lo que absorbe dos bloques y una tarjeta anidada.
- Del contrato, sin inventar nada: `PageIntro`, `Section`, `StatusBadge`, `DescriptionList` /
  `LabelValue` / `Item`, `DangerZone`, `EmptyState`, `Spinner`.

### Qué no tocar

- **`CredentialErrorPanel` entero.** Es el mapa cerrado de RT-08 hecho bien: cinco códigos con
  {título, explicación, acción}, `tone: 'step'` para los dos que no son culpa de la credencial, y un
  `UNMAPPED` (`:122-128`) que ante un código desconocido muestra lo que dijo el servidor y **no da la
  credencial por inválida**. Cambia de lugar en la página, no de contenido.
- **El foco al bloque de estado al llegar desde un aviso de credencial rota** (`:178-186`, RT-18), y
  que sea el bloque el que reciba el foco y no un botón de adentro: lo primero que hay que leer es en
  qué estado está.
- **La validación local del identificador de proyecto antes de subir el archivo** (`:684-689`), con su
  comentario: el error frecuente es pegar el nombre en vez del identificador, y eso se ve sin gastar
  una llamada.
- **`ConfirmDestructive` para el reemplazo** (`:842-858`), con su texto que aclara que los dominios y
  el historial quedan intactos y que si la dirección cambia hay que volver a autorizar.
- **`preserveScroll` en `verify`** (`:151-155`) y el `role="status"` / `role="alert"` de la cabecera
  (`:362`): el resultado se reescribe donde la persona está mirando.
- **`spellOut`** (`:1067`): la huella dictada en grupos de cuatro para un lector de pantalla.
- La distinción de que `NO_PROPERTIES` no es una falla. Es la decisión más fina de la vista y está
  argumentada en tres lugares del código.

### Reglas en juego

- **RT-06**: sólo huella y dirección; el ejemplo del archivo es el inventado del servidor y nunca el
  de la persona. `KeyFileField` es el único componente que toca el archivo, y muestra nombre y
  tamaño, nada más.
- **RT-08**: el mapa cerrado se conserva tal cual; los errores de validación siguen junto a su campo
  (`FieldError` + `fieldErrorProps`), y el resultado de «comprobar» que no llegó a guardarse —cupo
  agotado— sigue junto al botón que lo disparó (`:442`).
- **RT-07**: sin la guía duplicada no queda ningún control que no lleve a ningún lado. En el estado
  vacío no se ofrece nada de dominios (el checklist V2 lo pide explícitamente).
- **RT-11**: la confirmación va en el lugar (`CopyButton`), no en un toast.
- **RT-18**: esta vista sigue siendo el destino de `CREDENTIAL_INVALID` y `CREDENTIAL_REVOKED`, y el
  bloque recibe el foco y está desplegado al llegar. Lo que se suprime es que el aviso se muestre
  **acá**, no que exista.
- **RT-01**: `TimezoneFootnote` al pie, ya está.
- Contrato §3.3: ninguna `Card` dentro de otra `Card`, ninguna `Card` con `p-*` propio, y ninguna
  página escribe el string de `CardTitle` a mano.

---

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

**1. El `AccountNotice` se repite consigo mismo en su propia página de destino.** Es un problema
transversal, no de V2. `AppLayout` rinde `AccountNotices` en **toda** pantalla
(`AppLayout.tsx:68`), y los cinco avisos de `apps/accounts/services.py` apuntan a `/settings` o a
`/domains?access_state=ACCESS_LOST`. O sea: en `/settings` con la credencial rota, y en `/domains`
filtrado por acceso perdido, el aviso repite lo que la página ya dice y ofrece un botón que navega a
donde ya estás. **Regla para el contrato: un `AccountNotice` no se rinde en la página que es su
propio `action_path`.** Es una comparación de una línea en `AccountNotices` y arregla el caso más
visible de todos —la primera pantalla del producto, `/onboarding`, con un banner que empuja a
`/settings`—.

**2. `StatusBadge` necesita un sexto uso declarado: el estado de un paso.** Los cuatro `CheckState`
del recorrido (`PASSED` / `MISSING` / `BLOCKED` / `UNCONFIRMED`) mapean limpio a cuatro de los cinco
tonos, y `UNCONFIRMED` es **exactamente** RT-03 —«Google no contestó, todavía no sabemos»— aplicado a
un paso en vez de a una URL. Conviene que el contrato §3.7 lo diga: `tone="unknown"` no es sólo para
`UNKNOWN` de cobertura, es para todo «todavía no preguntamos», y `BLOCKED` es `neutral`, nunca
`critical`, porque no falló nada.

**3. Hay dos mapas cerrados de errores con dos anatomías.** `ServerErrorNotice` (169 líneas, códigos
de nuestro servidor) y `CredentialErrorPanel` (240 líneas, códigos de Google) son los dos `Alert` con
título + explicación + acción, y los dos hacen lo correcto ante un código desconocido. **No hay que
fusionarlos** —los dominios son distintos de verdad— pero sí declarar en §3.10 que comparten
anatomía: `Alert` → icono → `AlertTitle` → explicación → nota en T6 → fila de acciones. Hoy eso
coincide por casualidad y la próxima vista que agregue el suyo lo va a dibujar distinto.

**4. Las páginas de tarea usan una columna angosta con tres anchos distintos.** `max-w-3xl` en
Settings (`:213`) y en el Wizard (`:207`), `max-w-2xl` en `Domains/Create.tsx:89`. El contrato §2
resuelve «una columna o dos» pero no dice nada del ancho de la columna de tarea. Hace falta un
`TaskColumn` con un solo valor, y una regla: **una pantalla cuyo propósito es completar un formulario
o seguir un procedimiento se rinde en columna angosta; una que muestra datos usa el ancho completo.**
Si no, la diferencia entre V2 y V4 va a parecer un descuido y no una decisión.

**5. Login necesita una excepción escrita a la regla verificable 1.** «Ninguna página escribe `<h1>`»
es correcta para las trece vistas con sesión, y es imposible para la única sin `AppLayout`. La
excepción tiene que estar en el contrato con nombre y motivo, o alguien la va a «arreglar».

**6. Cuando el mismo contenido lo escribe el servidor una vez, la interfaz igual lo duplica.**
`apps/onboarding/content.py` existe precisamente para que los pasos se escriban una sola vez, y su
docstring lo argumenta. Aun así, esos pasos se **dibujan** con tres componentes distintos —`GuideStep`,
`StepPanel` y `StepProgress`— y ocho piezas más están duplicadas entre `Settings/Index.tsx` y
`Onboarding/Wizard.tsx`: el mapa `PERMISSIONS`, `formatSize`, el bloque de autorización en Search
Console, la lista de propiedades, la zona de carga del archivo, las URLs de las pantallas de Google,
`withProject` y las dos listas `divide-y rounded-lg border` que el contrato §3.6 ya señala
(`Settings/Index.tsx:609` y `Onboarding/Wizard.tsx:1102`). **Una sola fuente en el servidor no
produce una sola forma en la pantalla**: la unificación hay que hacerla también del lado del
componente, y conviene que la revisión final lo compruebe explícitamente.

**7. Un `<details>` a mano puede tapar el tamaño real de una vista.** V2 mide 900 px y entra justo
sólo porque tres `<details>` están cerrados en el estado verificado; el estado que la vista existe
para resolver los abre. Cuando la medición de una pantalla dependa de un plegado, hay que medir el
estado peor, no el mejor.
