# Grupo F · La cuenta — V11 Claves de API y V12 Sesiones abiertas

## V11 · Claves de API — `/api-keys` — [`frontend/pages/ApiKeys.tsx`](frontend/pages/ApiKeys.tsx)

**Veredicto**: reestructurar — **severidad** 4/5
**El problema en una línea**: la vista más vacía del producto ocupa 1004 px porque tiene todo
desplegado a la vez, y el único momento irrepetible de toda la aplicación —el valor completo de una
clave— está construido sobre un `<div>` no modal del que se sale por accidente.

### Qué pasa hoy

- **El alta ocupa el primer tercio de la pantalla todos los días para usarse dos veces por año.**
  La `Card` «Crear una clave» (`ApiKeys.tsx:372-430`) es un formulario de **un** campo que vive
  arriba de la lista, siempre. Y su botón lleva `className="mt-8"` (`:411`) para alinearse a mano
  con un `Input` que está al lado: un número mágico que existe sólo porque el formulario está en
  fila y no en un contenedor propio.
- **El panel de revelación miente sobre lo que es.** `KeyRevealPanel` (`:228-287`) pone
  `role="alertdialog"` sobre un `<div tabIndex={-1}>` que no es modal: no atrapa el foco, no tapa la
  barra lateral, no bloquea la navegación. Un lector de pantalla anuncia un diálogo que no lo es, y
  cualquiera puede tabular hacia afuera, clickear «Dominios» o apretar F5 y perder el secreto para
  siempre. El comentario del archivo (`:220-227`) razona con precisión por qué no puede ser un aviso
  efímero, y después lo implementa con la única forma que no protege: la que sí protegía era el
  modal.
- **Las «8 tarjetas» que mide el brief no son ocho tarjetas.** Son 2 `Card` de verdad, el
  `EmptyState` punteado, los 4 botones y el `Input`: en `radix-nova` el `Button` base es
  `rounded-lg border` (`ui/button.tsx:8`) y el `Input` también, así que el contador de cajas con
  borde los cuenta como tarjetas. El problema real de V11 no es tener tarjetas de más: es que sus
  dos `Card` reales conviven con un tercer grosor de borde inventado a mano —`rounded-lg border-2
  p-5` en `:245`— y con un `p-5` que no pertenece a la escala 4·8·12·16·24.
- **Tipografía fuera del contrato, en cuatro lugares.** Dos `<h2 className="text-lg font-medium">`
  (`:174`, `:445`) — 18 px, prohibido — y dos `CardTitle className="text-base"` (`:374`, `:518`) que
  repiten a mano lo que la primitiva ya da.
- **El botón del estado vacío no hace lo que dice.** «Crear la primera clave» (`:189`) ejecuta
  `nameField.current?.focus()`: no crea nada, mueve el foco a un campo que está 400 px más arriba y
  fuera de la vista. Es la acción más importante de la pantalla cuando la cuenta está vacía, y es la
  que menos cumple.
- **La segunda tabla no es una tabla del sistema.** Las claves revocadas se rinden con `ui/table` a
  pelo, sin paginar y sin conteo (`:460-498`), al lado de un `DataTable` que sí hace las dos cosas:
  dos listas del mismo objeto, con dos formas distintas, a 300 px de distancia. Y las revocadas no
  se borran nunca (`models.py:150-154`, «la fila queda para poder auditarla»), así que es
  exactamente el tipo de lista que RT-09 obliga a paginar.

### En qué orden debería mirarse

1. **Cuando acaba de crearse una clave: el valor, y nada más.** Un modal que tapa el resto de la
   pantalla. No hay «segundo» hasta que la persona dice que la guardó.
2. **Qué está vivo hoy**: la tabla de claves vigentes, con su último uso.
3. **Qué estuvo vivo y hasta cuándo**: la tabla de revocadas. Es lo que se busca después de un
   incidente (FR-061), así que se ve al llegar y no detrás de un filtro.
4. **Cómo la usa el pipeline**: plegado, salvo cuando la cuenta todavía no tiene ninguna clave.
5. El pie de zona horaria.

El alta **no aparece en este orden a propósito**: deja de ser contenido de la página y pasa a ser un
botón del encabezado que abre un diálogo.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `Card` «Crear una clave», siempre en pantalla (`:372-430`) | `Dialog` que abre el botón «Crear una clave» del slot `actions` | **Dialog** (árbol §4, paso 3: formulario de ≤5 campos que bloquea el flujo) | Un campo. El contrato ya lo nombra por su nombre: «Dialog … crear/renombrar clave de API». Adentro del diálogo el formulario va en vertical y el `mt-8` desaparece solo |
| El cuarteto `Label` + `Input` + ayuda + `FieldError` armado a mano (`:381-409`) | `FieldSet` / `Field` / `FieldLabel` / `FieldDescription` / `FieldError` | **`field`** (instalada, sin usar en ninguna página) | Es el formulario más chico del producto: si acá no se adopta la primitiva, no se adopta en ninguno |
| `KeyRevealPanel`, `<div role="alertdialog">` no modal (`:228-287`) | `SecretRevealDialog`, sobre `AlertDialog` | **AlertDialog** (árbol §4, paso 1: irreversible) | Ver «El momento de la clave», abajo |
| `<h2 className="text-lg">` × 2 (`:174`, `:445`) | `Section` con su `h2` en T3 | **`Section`** (§3.2) | Un `<section className="space-y-3">` con un `h2` a 18 px es la pieza que el contrato existe para reemplazar |
| Tabla de revocadas con `ui/table` a pelo (`:460-498`) | `DataTable` con `paramPrefix="revoked"` | **`DataTable`** (§3.5) + `table_props(..., prefix='revoked')` en `views.py:142-145` | Las revocadas se acumulan para siempre: RT-09 y regla 13. El comentario de `:454-459` tiene razón en que hoy dos `DataTable` se pelearían `page` y `sort` — eso es un problema de nombres, no una ley: se resuelve con un prefijo |
| El gris (`text-muted-foreground`) como único portador de «esta clave está muerta» (`:476-492`) | `StatusBadge tone="critical" icon={Ban}` «Revocada», **debajo del nombre, en la primera celda**; las filas dejan de ser grises | **`StatusBadge`** (§3.7) | RT-04: el estado no se comunica sólo por color, y hoy se comunica sólo por color más un encabezado de sección que no sobrevive a un recorte de pantalla pegado en un informe de incidente. Es además el mismo lugar donde V12 pone «Esta sesión»: una sola anatomía para el grupo |
| `Card` «Cómo la usa tu pipeline» (`:516-546`) | La misma `Card`; su cuerpo adentro de un `Collapsible`, **abierto sólo si la cuenta no tiene ninguna clave** | **Collapsible** (árbol §4, paso 6: un solo bloque secundario) | Es material de referencia que se lee una vez. El título queda siempre visible, así que no se esconde nada; lo que cambia es que deja de costar 250 px a quien ya integró su pipeline |
| `EmptyState` cuya acción sólo enfoca un campo (`:183-191`) | El mismo `EmptyState`, con la acción abriendo el `Dialog` de alta | **`EmptyState`** (§3.8, sobre `@shadcn/empty`) | Un botón que dice «Crear la primera clave» tiene que crear |
| `<p role="status" className="min-h-5">` montado siempre (`:162-169`) | Adentro de la `Section` «Claves vigentes», arriba de la tabla, y **sólo cuando hay algo que decir** | Se queda en la página (§4, paso 11) | El anuncio llega por navegación completa de Inertia, así que la región viva no necesita preexistir. Reservar el alto cuesta 20 px + los 24 px del `gap-6` de `<main>` en **cada** visita, para un mensaje que aparece una vez por revocación |
| Botón «Crear una clave» | Slot `actions` del encabezado | Una línea, un botón, no destructivo | Regla 21 lo permite: un solo control, sin badge y sin fecha |

**Alto estimado después**: ~640 px con dos listas pobladas y ~560 px con la cuenta vacía, contra los
1004 px de hoy. Las dos entran enteras arriba del pliegue.

**El momento de la clave — cómo se diseña «copiala ahora porque no la vas a volver a ver»**

El árbol de decisión del contrato se recorre en orden y para en el primero que da sí:

- **Paso 1 — ¿es destructivo o irreversible? Sí. → `AlertDialog`. Se termina acá.** Lo irreversible
  no es crear la clave: es **cerrar el panel**. El costo de equivocarse recae entero sobre la
  persona y no se puede deshacer desde ningún lado.
- El paso 2 —¿tiene identidad propia, URL propia, alguien puede llegar desde afuera?— **descarta la
  página propia por el motivo exacto que hace especial a esta pantalla**: una URL implica que se
  puede volver a pedir, y RT-06 dice que no se vuelve a pedir nunca. Una página `/api-keys/<id>/new`
  sería un contrato roto escrito en la barra de direcciones.
- El `Sheet` (paso 4) queda descartado por definición: cierra con clic afuera y con `Escape`, que
  son las dos formas de perder el dato sin decidirlo.

`SecretRevealDialog`, entonces, sobre `AlertDialog` —la misma primitiva de `ConfirmDestructive`,
por las mismas tres razones: el clic afuera no cierra, el foco queda atrapado, y al cerrarse vuelve
al control que lo abrió—. Anatomía:

```
AlertDialogTitle      «Esta es tu clave»                                   ← T3
AlertDialogDescription «Guardala ahora: por seguridad no vamos a poder…»   ← T5, max-w-prose
<code select-all wrap-anywhere>  irk_a1b2…  </code>  +  <CopyButton/>      ← nunca un <input type="text">
AlertDialogFooter      [ Ya la guardé ]  ·  «Queda listada como “deploy-produccion”, prefijo a1b2…»
```

Las decisiones que lo hacen a prueba de descuidos, en orden de importancia:

1. **Es modal de verdad.** Mientras está abierto, la barra lateral, el encabezado y la lista no son
   clickeables ni tabulables. El accidente más probable de hoy —clickear una entrada del menú— deja
   de ser posible.
2. **Salida única, con compuerta.** El pie tiene dos estados y son una máquina de estados **dentro
   del mismo diálogo**, no un segundo diálogo anidado (el contrato prohíbe anidarlos):
   - *Sin copiar*: el botón es `variant="outline"` y a su lado, en T6 muted, «Todavía no la
     copiaste». Al apretarlo, el pie se reemplaza por «¿Cerrar sin copiar? No vas a poder volver a
     verla.» con [Volver] y [Cerrar igual].
   - *Ya copiada*: `CopyButton` muestra «Copiado» y el pie pasa a un único botón primario «Ya la
     guardé» que cierra directo. **Un clic si hiciste lo correcto; dos si no.** La fricción cae
     exactamente sobre el caso de pérdida y sobre ningún otro.
3. **`Escape` pasa por la misma compuerta.** Sin copiar, `onEscapeKeyDown` hace `preventDefault` y
   muestra el segundo estado del pie — el mismo mecanismo que `ConfirmDestructive.tsx:105-107` ya
   usa mientras corre una acción. Copiada, `Escape` cierra.
4. **El botón «Atrás» del navegador.** Es el único vector que la modalidad no cubre: Radix no
   empuja historial, así que Atrás saldría de la página con el diálogo abierto. El diálogo **empuja
   una entrada de historial al abrirse**, de modo que el primer Atrás lo cierra —por la compuerta— en
   vez de irse. Cuesta una entrada de historial y es la única manera de que Atrás sea seguro.
5. **Recargar y cerrar la pestaña.** `beforeunload` armado mientras el valor no se copió. El texto
   lo pone el navegador y no se puede personalizar; alcanza igual, porque lo que hace falta es la
   pausa, no el mensaje.
6. **Si el portapapeles falla.** Hoy `CopyButton.tsx:33-36` se traga el error en silencio: el icono
   no cambia y nadie sabe si copió. Adentro de este diálogo eso es una clave perdida. `CopyButton`
   pasa a avisar el resultado hacia afuera, y el diálogo muestra «No pudimos usar el portapapeles.
   Seleccioná el texto y copialo a mano» sin salir del estado «sin copiar». El `<code select-all>`
   ya existe justamente para ese caso (`:258-262`) y se conserva.
7. **RT-06 se cumple igual que hoy y por los mismos motivos**: el valor sigue viviendo sólo en el
   estado de React, sigue llegando por el `fetch` de `:339-347` y no por props de Inertia, no entra
   en la URL, no entra en un toast, y `<code>` nunca se convierte en un `input type="text"` «para
   que se pueda seleccionar»: `select-all` ya lo resuelve.
8. **Encadenado con el alta, sin anidar.** El `Dialog` de creación se cierra y el
   `SecretRevealDialog` se abre en su lugar, desde la misma transición de estado
   (`'idle' | 'creating' | 'revealing'`). No hay un instante en el que ninguno de los dos esté
   montado y la respuesta ya haya llegado.

**Movimiento** (§3.12): entrada 200 ms `cubic-bezier(0.23, 1, 0.32, 1)` desde `scale(0.97)` +
`opacity: 0` con `transform-origin: center` —es modal, no cuelga de un disparador—; salida 150 ms.
Sin retardo de apertura ni entrada escalonada: cada milisegundo antes de que el diálogo tape la
pantalla es un milisegundo en el que se puede apretar otra cosa. `CopyButton` cambia de icono con un
cruce de opacidad de 120 ms y lleva `active:scale-[0.97]`. Todo con su variante `motion-reduce:`.

### Componentes compartidos que necesita

- **`SecretRevealDialog`** — el patrón «este dato existe una sola vez», no «la clave de API»: si
  mañana hay un secreto de webhook, es el mismo diálogo.
  `{ open: boolean; title: string; description: string; value: string; valueLabel: string;
  footnote?: ReactNode; onDismiss: () => void }`. Sobre `AlertDialog`. Reemplaza `KeyRevealPanel`
  entero, incluido el `role="alertdialog"` pegado a un `<div>`.
- **`Section`** (§3.2) — reemplaza los dos `<section className="space-y-3">` con `h2` a 18 px.
- **`StatusBadge`** (§3.7) — «Revocada» (`tone="critical"`, icono `Ban`) en la primera celda de la
  tabla de revocadas.
- **`DataTable` con `paramPrefix?: string`** — y su contraparte `table_props(..., prefix=)` del lado
  del servidor, para que dos listas convivan en una página sin disputarse `page` y `sort`.
- **`CopyButton` con `onCopy?: (success: boolean) => void`** — hoy el fallo del portapapeles es
  silencioso; es un cambio de tres líneas que le sirve a toda vista que copie algo.
- **`EmptyState`** (§3.8) — se conserva la firma; sólo cambia qué hace la acción.
- Primitivas a instalar que esta vista consume: `dialog`, `collapsible`, `empty`, `spinner`. Y
  `field`, que ya está instalada y no la usa nadie.

### Qué no tocar

- **El `fetch` del alta y por qué no pasa por el formulario de Inertia** (`:289-297` y
  `views.py:156-179`): es lo que impide que el secreto quede guardado en el historial de navegación.
  El `Dialog` cambia dónde se dibuja el formulario, no cómo viaja la respuesta.
- **Las dos listas separadas y visibles al llegar** (FR-061). Nada de una tabla con un filtro
  «Mostrar revocadas» apagado por defecto: eso es exactamente lo que el checklist prohíbe.
- **La ausencia total de acción en las filas revocadas.** Ni un «Revocar» apagado, ni un
  «Reactivar»: no hay acción posible ahí (RT-07).
- **Los textos.** La microcopy de V11 está escrita palabra por palabra en el checklist (líneas
  1271-1287) y el archivo la respeta. Se mueven de lugar; no se reescriben.
- **El `aria-label` único por fila del «Revocar»** (`:106-109`) y su comentario.
- **La validación del nombre en el cliente con el mismo criterio que el servidor, y el `focus()` al
  campo cuando falla** (`:331-335`, `:313-318`).
- **`recent` como estado local que se pierde al navegar.** Es la promesa del contrato, no un
  descuido a arreglar.

### Reglas en juego

- **RT-06** — el valor sigue viviendo sólo en el estado de React, no vuelve a pedirse nunca, y no
  hay ningún `input type="text"` con el secreto adentro. El diálogo modal no agrega superficie: la
  quita, porque el valor deja de estar en un panel del que se puede salir sin decidirlo.
- **RT-01 / RT-02** — «Creada», «Último uso» y «Revocada» siguen cada una en su columna, con
  `DataTimestamp`, y la vista termina en `TimezoneFootnote`.
- **RT-04** — «Revocada» pasa a ser icono + palabra en vez de un renglón gris.
- **RT-07** — ninguna fila revocada con un control apagado; ninguna mención de cuotas por plan.
- **RT-08** — el error de validación sigue junto al campo (ahora vía `FieldError` de la primitiva) y
  el del servidor sigue en `ServerErrorNotice`, ahora arriba del grupo de campos dentro del diálogo.
- **RT-09 / regla 13** — la tabla de revocadas pasa a paginar en el servidor.
- **RT-10** — la revocación conserva `ConfirmDestructive` tal cual.
- **RT-11** — la clave nunca en un toast; el resultado de la revocación sigue escrito en la pantalla
  de destino y no desaparece solo.
- **Contrato §1** — se van los dos `text-lg` y los dos `CardTitle className="text-base"`.
- **Contrato §2 / regla 7** — se van el `p-5` y el `border-2` del panel; el espaciado sale de
  `--card-spacing`.
- **Regla 21** — al `actions` va un botón y nada más.

---

## V12 · Sesiones abiertas — `/sessions` — [`frontend/pages/Sessions.tsx`](frontend/pages/Sessions.tsx)

**Veredicto**: rehacer — **severidad** 4/5
**El problema en una línea**: 1543 px, 340 nodos y 203 bloques de texto para contestar una pregunta
de dos palabras —«¿hay alguien más adentro?»— porque cada una de las 22 filas trae adentro un
acordeón nativo, un botón suelto y una celda que cambia de forma tres veces.

### Qué pasa hoy

- **La fila no es una fila: es un bloque.** Cada una lleva el nombre del dispositivo, un
  `<details>` nativo «Agente declarado» (`:281-290`) y un `Button variant="outline"` (`:317-330`).
  Veinte de las veintidós filas tienen el `<details>` —porque el agente no se pudo reconocer— y eso
  las lleva a **57 px** cada una. La tabla entera podría medir la mitad.
- **Las «21 tarjetas» que mide el brief no son tarjetas: son los 21 botones.** `Sessions.tsx` **ni
  siquiera importa `card`** (está en la tabla del brief). Son 21 `Button variant="outline"`, y en
  `radix-nova` el `Button` base es `rounded-lg border` (`ui/button.tsx:8`), así que el contador de
  cajas con borde los lee como tarjetas. El diagnóstico correcto no es «cards a lo loco»: es **una
  vista sin ninguna estructura**, donde 21 controles idénticos apilados en una columna hacen el
  ruido que en otras vistas hacen las tarjetas.
- **Cero encabezados.** No hay ni un `h2` ni un `h3` en todo el archivo: la única jerarquía de la
  página es el `h1` que pone el armazón. Nada agrupa nada.
- **La acción destructiva más grande de la aplicación vive en el slot del encabezado**
  (`:119-141`). La regla 20 del contrato lo prohíbe con esas palabras, y por buen motivo: «Cerrar
  todas las demás» está a un clic de distancia del disparador de la barra lateral, en la franja
  donde el resto de las vistas pone acciones inocuas.
- **La tabla no pagina.** Es `ui/table` a pelo (`:178-211`) y el servidor devuelve la lista entera
  (`services.py:313-330`, `views.py:74-83`). Con 22 sesiones ya se va a 1543 px; una cuenta
  comprometida o un cliente que reingresa en cada corrida de CI produce cientos, y la página crece
  sin techo. Regla 13 y RT-09.
- **La celda de acciones tiene tres formas distintas según el estado**: una frase de cinco palabras
  en la fila actual, un «Cerrando…» de texto plano durante la acción, y un botón en el resto
  (`:307-331`). Tres anatomías para una columna.

### En qué orden debería mirarse

1. **¿Cuál soy yo?** La fila marcada «Esta sesión». Sin eso, la acción principal de la vista es una
   ruleta — lo dice el propio checklist.
2. **¿Cuántas más hay, y cuándo estuvieron activas?** La línea de conteo de la tabla y la columna
   «Última actividad», que además es el orden por defecto.
3. **¿Alguna me resulta ajena?** Dispositivo y dirección de origen, en dos columnas, sin nada
   plegado que obligue a abrir veinte cosas para comparar.
4. **El detalle de una en particular**, bajo demanda: la cadena declarada completa, las fechas en
   absoluto, todo junto.
5. **Cerrar**: primero una sola, desde el menú de su fila; y recién al final de la página, cerrar
   todas las demás.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `ui/table` a pelo, sin conteo ni paginación (`:178-211`) | `DataTable` | **`DataTable`** (§3.5), con `table_props` en `views.py:66-83` | RT-09 y regla 13. Trae además lo que hoy falta: conteo anunciado por región viva, encabezado fijo, `aria-sort`, contenedor desplazable de `max-h-[70vh]` y `caption` accesible. **Costo real a nombrar**: paginar en el servidor exige que el descarte de sesiones vencidas deje de hacerse en Python fila por fila (`services.py:322-328`) |
| Los 20 `<details>` «Agente declarado» dentro de la celda (`:281-290`) | `DetailSheet`, con `?session=<id>` en la dirección | **Sheet** (árbol §4, paso 4 — y el contrato ya lo nombra: «el agente declarado en Sesiones») | Es el detalle de una fila al que hay que volver sin perder la lista. Un `<details>` nativo no es del sistema de diseño, no se ve igual que nada, y veinte de ellos empujan la página a 1543 px |
| Los 21 `Button` «Cerrar» sueltos en la última celda (`:317-330`) | `RowActions`: `DropdownMenu` con «Ver el detalle» + `DropdownMenuSeparator` + «Cerrar la sesión» | **DropdownMenu** (árbol §4, paso 10) | La fila pasa a tener **dos** acciones —ver el detalle y cerrar—, así que el menú es lo que corresponde. Y en una pantalla que se visita por sospecha, la decisión viene antes que la acción: enterrar el cierre un nivel es correcto, no es fricción gratuita |
| La primera celda con el nombre del dispositivo (`:262`) | El mismo nombre, como `<Link href="?session=<id>">` | **`<Link>` de verdad** (§3.5, regla 19) | Así funcionan Cmd+clic, el clic del medio y el teclado, y el detalle queda enlazable. La fila entera **no** se vuelve clickeable |
| `Badge variant="outline"` «Esta sesión» (`:269-273`) | `StatusBadge tone="neutral" icon={MonitorCheck}`, en el mismo lugar: debajo del nombre, primera celda | **`StatusBadge`** (§3.7) | Hoy es sólo palabra; RT-04 pide icono + palabra. Y queda en el mismo lugar donde V11 pone «Revocada»: una anatomía de primera celda para todo el producto |
| «Es la que estás usando ahora» en la celda de acciones (`:308-311`) | Al `DetailSheet` de esa sesión | Sheet | En una columna de acciones alineada a la derecha, una frase de cinco palabras compite con 21 controles. El badge de la primera celda ya lo dice en la lista; la explicación va donde hay lugar para explicar. La fila actual queda **con la celda de acciones vacía**, que es la señal más fuerte de todas: la única que no se puede cerrar es la única sin menú |
| «Cerrando…» como tercera forma de la celda (`:312-315`) | Adentro del `ConfirmDestructive`, que ya se queda abierto mientras la acción corre | **`Spinner`** (§3.9) + gerundio | `ConfirmDestructive.tsx:140-154` ya hace exactamente esto. La celda no necesita un cuarto estado |
| `ConfirmDestructive` de «Cerrar todas las demás» en el slot `actions` (`:119-141`) | `DangerZone` al pie de la página | **`DangerZone`** (§3.11) + AlertDialog | Regla 20: ninguna acción destructiva en `actions`. El `Item` lleva el nombre en T4, la consecuencia en T5 muted y el `Button variant="destructive"` a la derecha. El diálogo conserva la cantidad en el título, que ya tiene |
| `<p>` «Sólo tenés esta sesión abierta…» (`:171-176`) | `description` de la `Section` de la lista, condicional | **`Section`** (§3.2) | Es la respuesta a la pregunta de la vista y tiene que estar antes de la lista, en T5 muted y a `max-w-prose`. Deja de ser un párrafo suelto entre dos bloques |
| Cero `h2` | `Section title="Sesiones abiertas" titleHidden` para la lista + el `h2` visible de la `DangerZone` | **`Section`** | El nivel existe siempre; lo que se oculta es el dibujo, porque repetir el título del encabezado 40 px más abajo no informa nada |
| `<p role="status" className="min-h-5">` montado siempre (`:148-156`) | Adentro de la `Section`, arriba de la tabla, sólo cuando `closed_count !== null` | Se queda en la página | El comentario de `:143-147` ya explica que el anuncio llega por navegación completa: la región viva no necesita preexistir, y el `.focus()` de `:84-86` se conserva tal cual |
| Slot `actions` del encabezado | **Vacío** | — | Un encabezado sin acciones no es un encabezado incompleto |

**Qué es una sesión para el usuario, entonces** — la unidad de la lista es **una fila de
`DataTable`**, no una tarjeta y no un bloque:

| Columna | Contenido | Notas |
|---|---|---|
| Dispositivo | `<Link>` con `describe(session)` en `font-medium`; debajo, como mucho **un** `StatusBadge` («Esta sesión») | La única celda de dos líneas |
| Dirección de origen | `<code>` T6, `wrap-anywhere`; «Sin dirección registrada» cuando falta | Nunca «Ubicación» |
| Última actividad | `DataTimestamp` | Orden por defecto, descendente |
| Inicio | `DataTimestamp` | |
| Acciones | `RowActions` (`⋯`, `size="icon-sm"`, nombre accesible único por fila) — **vacía** en la fila actual | |

Con `py-3` y todo de una línea salvo la primera celda, la fila baja de 57 px a ~48 px, los 20
`<details>` desaparecen, y las 22 filas viven adentro del contenedor de `max-h-[70vh]` con
encabezado fijo. **La página deja de medir 1543 px: la estructura entera entra arriba del pliegue y
lo único que se desplaza es la lista, dentro de sí misma.**

**Qué lleva el `DetailSheet`** (sólo lectura, una acción — que es exactamente lo que el contrato
permite en un `Sheet`): dispositivo · navegador y sistema declarados · la cadena del agente completa,
en monoespaciada, `wrap-anywhere`, seleccionable y con `CopyButton` · dirección de origen · última
actividad e inicio en **absoluto** (`formatAbsolute`, no relativo: acá hay lugar) · si es la actual,
la frase «Es la que estás usando ahora» que hoy vive apretada en la celda · y al pie, «Cerrar la
sesión», salvo en la actual. Estado en la dirección (`?session=<id>`), como manda el contrato para
todo patrón de revelación. Si la sesión desaparece de la lista —porque se cerró—, el panel se cierra
y el parámetro se borra. Abajo de `md` se presenta como `Drawer`, que es la misma pieza y no una
decisión aparte.

**Las dos acciones destructivas**

- **Cerrar una**: ítem último del `DropdownMenu`, después de un `DropdownMenuSeparator`, que abre el
  `ConfirmDestructive` que ya existe con su título que nombra el dispositivo y su consecuencia que
  dice la fecha de inicio y la dirección (`:215-232`). Se conserva entero.
- **Cerrar todas las demás**: `DangerZone` al pie, con la cantidad en el título del diálogo
  («¿Cerrar las otras 21 sesiones?») y en el verbo del botón, tal como hoy. Cuando `others.length`
  es cero la `DangerZone` **no se rinde**: nada apagado, nada con «Próximamente» (RT-07).
- Ninguna de las dos vive en `actions`. Ninguna de las dos está en la primera celda de la tabla.

**Movimiento** (§3.12): el `DropdownMenu` de la fila escala desde su disparador
(`transform-origin: var(--radix-dropdown-menu-content-transform-origin)`) en 150 ms `ease-out`; el
`Sheet` entra con `translateX(100%) → 0` en 200 ms y sale en 150; el `AlertDialog`, 200/150 desde
`scale(0.97)`. **Ordenar, paginar y cerrar una sesión no se animan**: son navegaciones repetidas y
el único aviso es el intercambio por el esqueleto. Todo con `motion-reduce:`.

### Componentes compartidos que necesita

- **`RowActions`** — el `DropdownMenu` de la última celda, con anatomía fija y una regla que hoy
  vive escondida en un comentario de esta vista.
  `{ label: string; items: RowActionItem[]; destructive?: RowActionItem & { confirm: ConfirmProps } }`.
  Disparador `⋯` `size="icon-sm"` con `aria-label` obligatorio y único; ítems de lectura arriba;
  `DropdownMenuSeparator`; el destructivo último, abriendo un `ConfirmDestructive` **montado fuera de
  la fila**. Ese último detalle es el que importa: `Sessions.tsx:68-74` ya razonó por qué —al
  confirmar, la fila desaparece de la respuesta del servidor y un diálogo montado adentro se
  desmontaría con ella, dejando el foco en ningún lado— y ese conocimiento tiene que subir al
  componente compartido en vez de quedarse en una vista. Reemplaza los 21 botones sueltos, y el
  contrato ya lo quiere también en Dominios, Sitemaps y Claves de API.
- **`DetailSheet`** — el detalle de una fila con su estado en la dirección.
  `{ paramName: string; openId: string | null; title: string; description?: string;
  children: ReactNode; action?: ReactNode }`. Sobre `Sheet` (`Drawer` abajo de `md`). Reemplaza los
  20 `<details>` y unifica con el único `Sheet` bien usado que ya existe, el de Cobertura — a
  reconciliar con la propuesta de ese grupo, porque tiene que ser **el mismo** componente.
- **`Section`** con `titleHidden` (§3.2) y **`DangerZone`** (§3.11): V12 es el primer usuario real de
  las dos.
- **`StatusBadge`** (§3.7) para «Esta sesión».
- **`DataTable`** (§3.5) — sin cambios de firma; lo que hace falta es del lado del servidor:
  `TableSpec(sortable={'activity': ('last_activity_at',), 'created': ('created_at',)},
  default_sort='-activity')` en `views.py`.
- Primitivas a instalar o adoptar que esta vista consume: `dropdown-menu` (instalada, sin usar),
  `separator`, `sheet` + `drawer`, `item`, `spinner`.

### Qué no tocar

- **`describe()`** (`:48-55`) y **`describe_user_agent()`** (`services.py:221-233`) con sus
  docstrings: no inventarle un nombre plausible a un dispositivo es la decisión más importante de la
  vista, y está bien argumentada.
- **«Dirección de origen» y nunca «Ubicación»** (`:186-191`). Ninguna geolocalización por IP.
- **`is_current` decidido por el servidor** (`views.py:65-83`). No se adivina en el navegador.
- **La fila actual sin acción de cierre**, y sin un control apagado en su lugar.
- **Los dos `ConfirmDestructive` montados a nivel de página** (`:68-74`). Cambia dónde se declaran,
  no que estén afuera de la fila.
- **El nombre accesible único de cada acción de cierre** (`:321-327`), que empieza por la palabra
  visible para que el comando de voz «cerrar» lo alcance.
- **El foco al anuncio después de cerrar** (`:79-86`).
- **`closedResult()`** (`:57-62`) y su singular/plural.
- **El `EmptyState` de «no hay ninguna sesión registrada»** (`:158-163`), que explica el caso raro en
  vez de dejar una tabla vacía.

### Reglas en juego

- **RT-01** — «Última actividad» e «Inicio» siguen con `DataTimestamp` en columnas propias; el
  absoluto se muestra entero en el `DetailSheet`; la vista termina en `TimezoneFootnote`.
- **RT-04** — «Esta sesión» pasa de palabra sola a icono + palabra.
- **RT-07** — la `DangerZone` no se rinde cuando no hay otras sesiones; la fila actual no lleva un
  «Cerrar» apagado.
- **RT-09 / regla 13** — la lista pasa a `DataTable` con paginación de servidor y total visible.
- **RT-10** — los dos diálogos conservan `ConfirmDestructive` tal cual, con el objeto nombrado y la
  consecuencia en futuro; la masiva lleva la cantidad en el título (§3.11).
- **RT-11 / RT-17** — el resultado sigue escrito en la pantalla de destino y enfocado; nunca en un
  toast.
- **RT-12** — «Cerrando…» pasa a `Spinner` + gerundio dentro del botón del diálogo; el resto de la
  pantalla sigue usable.
- **RT-15** — el nombre del dispositivo como `<Link>` de verdad hace que el detalle sea alcanzable
  por teclado, por Cmd+clic y por dirección.
- **Regla 19** — la fila entera nunca es clickeable.
- **Regla 20** — la acción destructiva sale del slot `actions`.
- **Contrato §1** — la vista deja de tener cero encabezados.

---

## Coherencia del grupo · dónde viven estas dos vistas en el menú

Hoy están separadas: «Claves de API» es la **única** entrada del grupo `Sistema` de la barra lateral
(`app-sidebar.tsx:89-91`) y «Sesiones abiertas» es un ítem del desplegable de la cuenta
(`nav-user.tsx:82-94`). Las dos ubicaciones tienen su docstring y las dos son coherentes por
separado: las claves son nuestras y no de Google; las sesiones son cosa de quien está adentro.

**Recomendación: revisarlo.** Sin tocar el armazón, y como anotación del reporte, no como propuesta
de rediseño. Tres motivos:

1. **Un grupo de una sola entrada no agrupa nada.** El rótulo «Sistema» no separa «Claves de API» de
   ninguna otra cosa, porque no hay otra cosa. Es un encabezado que ocupa una línea y no informa.
2. **Las dos pantallas contestan la misma pregunta.** «¿Quién y qué tiene acceso a mi cuenta?» Se
   visitan juntas y por el mismo motivo: una sospecha, o una integración nueva. Después de un
   incidente, obligar a buscarlas en dos lugares de naturaleza distinta —una entrada de menú y un
   ítem escondido dentro de un desplegable que nada anuncia— es fricción en el peor momento posible.
3. **La asimetría es la causa del síntoma que motivó este encargo.** V11 y V12 se diseñaron por
   separado porque viven en lugares distintos, y por eso no se parecen en nada: una usa `Card` y
   `DataTable`, la otra `ui/table` y `<details>`; una tiene sus botones en tarjetas, la otra
   veintiuno sueltos en una columna. Las dos vistas del mismo tema, con dos vocabularios.

La forma de arreglarlo, si el owner lo quiere: que el grupo `Sistema` pase a llamarse **«Tu cuenta»**
y contenga las dos entradas (`api_keys`, `sessions`), dejando en `nav-user` sólo «Salir». Vale
aclarar que eso **no** sería reestructurar el armazón: el encabezado de `AppLayout.tsx:1-13` autoriza
explícitamente «cambiar el CONTENIDO —entradas, rótulos, textos, íconos, destinos—» y prohíbe
cambiar la anatomía. Es una edición de dos arreglos de constantes.

**El argumento en contra, para que la decisión sea informada**: GitHub, Google y la mayoría de los
productos ponen sesiones y seguridad debajo del avatar, y quien viene de ahí las va a buscar ahí
primero. El contrapeso es que en este producto el desplegable de la cuenta tiene exactamente dos
ítems y ninguna otra superficie de cuenta, así que la convención no está sosteniendo un sistema: está
sosteniendo un ítem solo.

---

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

1. **La columna «Cards» de la tabla del brief cuenta cajas con borde, no tarjetas.** En `radix-nova`,
   `Button` (`ui/button.tsx:8`) e `Input` son `rounded-lg border`. Las 8 «tarjetas» de V11 son 2
   `Card` + el `EmptyState` + 4 botones + 1 input; las 21 de V12 son **21 botones y ninguna `Card`**
   —`Sessions.tsx` ni siquiera importa `card`—. Antes de escribir «cards a lo loco» sobre una vista,
   conviene mirar si el archivo importa `card`: en la mitad de los casos el diagnóstico correcto es
   el opuesto, que no hay **ninguna** estructura y que el ruido lo hacen N controles idénticos
   apilados.
2. **La regla 25 del contrato no se puede cumplir como está escrita.** `grep -r "transition-all"
   frontend/` da **siete** archivos, todos en `components/ui/` (`badge`, `button`, `progress`,
   `sidebar`, `switch`, `tabs`, `toggle`), y **cero** en `pages/` y `components/`. Vienen del estilo
   `radix-nova`. Propongo que la regla pase a ser `frontend/pages frontend/components
   --exclude-dir=ui` = cero, y que se abra una tarea aparte para `ui/button.tsx` y `ui/badge.tsx`,
   que son las dos primitivas que se ven en todas las vistas.
3. **La línea reservada para un anuncio que casi nunca aparece.** Las dos vistas del grupo montan
   siempre un `<p role="status" className="min-h-5">` (`ApiKeys.tsx:162`, `Sessions.tsx:148`). Cuesta
   20 px más los 24 px del `gap-6` de `<main>` en cada visita, para un mensaje que aparece una vez
   por acción. Como el anuncio llega por **navegación completa** de Inertia y no por actualización
   parcial, la región viva no necesita preexistir: alcanza con montarla ya con texto y enfocarla, que
   es lo que las dos vistas hacen. **Regla propuesta para el contrato**: una región viva de resultado
   se rinde sólo cuando tiene texto, vive adentro de la `Section` dueña de la acción, y nunca reserva
   alto.
4. **Un botón cuyo `onClick` sólo mueve el foco es un botón que miente** (`ApiKeys.tsx:189`). Cuando
   un `EmptyState` ofrece «Crear el primer X», tiene que abrir la cosa. Vale para todos los
   `EmptyState` del producto y es verificable de un vistazo.
5. **`role="alertdialog"` sobre un `<div>` no modal es peor que no ponerlo**: promete al lector de
   pantalla una modalidad que no existe, y el foco se va tabulando a la página de atrás sin que nada
   se cierre. Si hace falta modalidad, la primitiva es `AlertDialog`.
6. **La anatomía de la primera celda de una tabla, propuesta para el contrato §3.5**: un `<Link>` (o
   el nombre en `font-medium` si no hay detalle) y, debajo, **como mucho un** `StatusBadge`. Nada
   más. Es lo que V12 ya hace con «Esta sesión» y lo que V11 debería hacer con «Revocada» en vez de
   pintar la fila de gris. Un solo lugar, en todo el producto, donde vive la marca de una fila.
7. **`ConfirmDestructive` se declara fuera de la fila, siempre.** El razonamiento ya está escrito en
   `Sessions.tsx:68-74`: al confirmar, la fila desaparece de la respuesta del servidor y un diálogo
   montado adentro se desmonta con ella dejando el foco en el `body`. Eso tiene que vivir en
   `RowActions`, no en un comentario de una vista, porque le va a pasar igual a Dominios y a
   Sitemaps.
8. **Dos listas en una página se pelean la dirección.** El comentario de `ApiKeys.tsx:454-459` es
   correcto —dos `DataTable` se disputarían `page` y `sort`— pero la conclusión no tiene por qué ser
   «entonces una de las dos no es `DataTable`». Es un problema de nombres: `paramPrefix` en el
   componente y `prefix` en `table_props`. Lo va a necesitar cualquier vista que muestre dos listas.
9. **Cuando la regla 20 saca lo destructivo de `actions`, algunas vistas se quedan con el slot
   vacío**, y está bien. Un encabezado sin acciones no es un encabezado incompleto; llenarlo con algo
   inofensivo «para que no quede pelado» es cómo se llegó a que en V12 la acción más peligrosa del
   producto viva a un clic del disparador de la barra lateral.
10. **`CopyButton` traga el fallo del portapapeles en silencio** (`CopyButton.tsx:33-36`): el icono no
    cambia y no hay forma de distinguir «no lo apretaste» de «no se pudo». En V11 eso es una clave
    perdida; en el resto de las vistas es sólo confuso. Pedir `onCopy?: (success: boolean) => void` y
    un texto de fallo visible es un cambio chico que le sirve a todas.
11. **Sobre el árbol de decisión §4, un caso que conviene dejar escrito**: el paso 2 —«¿tiene URL
    propia?»— puede ser un **descarte activo**, no sólo un salto. Para la revelación de la clave, que
    la cosa tenga URL sería un contrato roto (RT-06 promete que no se vuelve a pedir), así que el paso
    2 no la manda a una página: la descarta. Vale la pena decirlo en el contrato, porque el árbol como
    está invita a leerlo sólo como «si hay URL, página».
12. **Movimiento, lo específico del grupo**: un diálogo que porta un dato irrepetible no lleva retardo
    de apertura, ni entrada escalonada, ni nada que se pueda interrumpir; entra en 200 ms desde
    `scale(0.97)` con `transform-origin: center` —es modal, no cuelga de un disparador— y sale en
    150 ms. Los menús de fila, en cambio, escalan **desde** su disparador
    (`var(--radix-dropdown-menu-content-transform-origin)`). Y cerrar una sesión, ordenar y paginar
    **no se animan**: son acciones que se repiten y el único aviso legítimo es el intercambio por el
    esqueleto.
