# Catálogo de componentes compartidos

La **tabla de equivalencias** de la implementación: qué se construye, con qué firma, qué reemplaza y
quién lo consume. Se fija **antes** de repartir las vistas, porque lo que se rompe siempre son las
costuras: un componente compartido cuya firma cambia después de que sus consumidores terminaron.

Reglas que valen para todo el catálogo:

- **Los identificadores van en inglés** —nombres de archivo, componentes, props, claves—. Los textos
  de interfaz, en español rioplatense con voseo.
- **Ningún componente de este catálogo escribe su propio borde de tarjeta.** El borde es de `Card`
  (`ring-1`, no `border`) y del contenedor de `DataTable`.
- **Ninguna pieza redefine espaciado.** El ritmo de 24 px entre secciones lo da `<main>`; el interior
  de una tarjeta sale de `--card-spacing`.
- Anatomías, escala tipográfica y árbol de decisión: [`design-contract.md`](./design-contract.md).

---

## Fase 1A · Instalación, limpieza y átomos

### Primitivas a instalar

```
npx shadcn@latest add @shadcn/empty @shadcn/item @shadcn/spinner @shadcn/accordion \
  @shadcn/collapsible @shadcn/dialog @shadcn/popover @shadcn/hover-card \
  @shadcn/button-group @shadcn/input-group @shadcn/kbd
```

Ya instaladas y que **hay que empezar a usar**: `field`, `tabs`, `dropdown-menu`, `separator`,
`tooltip`, `select`, `toggle-group`, `skeleton`. Descartadas por escrito: `breadcrumb`, `checkbox`,
gráficos nuevos.

### Limpieza

| Qué | Por qué |
|---|---|
| Borrar `frontend/components/data-table.tsx` | Demo del bloque `dashboard-01`. Pagina y ordena **en el cliente**, lo que viola RT-09 de entrada. **No lo importa ninguna página** |
| Borrar `frontend/components/login-form.tsx` | Copia en inglés, tres proveedores de identidad inexistentes, dos enlaces a `#`, «Sign up» y un `/placeholder.svg`. No lo importa nadie |
| Sacar `@dnd-kit/core`, `@dnd-kit/modifiers`, `@dnd-kit/sortable`, `@dnd-kit/utilities` | `data-table.tsx` era su único consumidor |

### `CardTitle` acepta `asChild` — el arreglo de raíz

`ui/card.tsx` rinde `CardTitle` como un `<div>`. Por eso hay vistas con títulos visibles y perfectos
que miden **cero encabezados**, y por eso arreglarlo página por página serían catorce archivos.

- `CardTitle` acepta `asChild` y se le pasa `h2` en toda `Card` que sea una sección de la página, o
  `h3` cuando la `Card` vive dentro de una `Section` que ya aporta su `h2`.
- **Excepción declarada para `MetricCard`**: ahí el encabezado es la **etiqueta**, no el valor
  —«T2 nunca es una etiqueta de encabezado»—, así que el `h3` va en `CardDescription` con `asChild` y
  `CardTitle` se queda como `<div>`.

### Los átomos

| Componente | Archivo | Props | Reemplaza |
|---|---|---|---|
| **`StatusBadge`** | `components/StatusBadge.tsx` | `{ tone: 'positive' \| 'attention' \| 'critical' \| 'neutral' \| 'unknown'; icon: LucideIcon; children: string; help?: string; className?: string }` | Las **dos** formas de badge que conviven: `rounded-md border` de `CoverageStateBadge` y `rounded-4xl` de los otros dos |
| **`Section`** | `components/Section.tsx` | `{ title: SectionTitle; description?: string; actions?: ReactNode; id?: string; tone?: 'default' \| 'destructive'; children: ReactNode; className?: string }`, con `SectionTitle = { text: string; isHidden?: boolean; level?: 'h2' \| 'h3'; className?: string }` | ~20 `<section className="rounded-lg border p-4">` y los `<h2 className="text-lg\|text-xl">` |
| **`SectionCard`** | `components/SectionCard.tsx` | `{ title: SectionCardTitle; description?: string; actions?: ReactNode; footer?: ReactNode; id?: string; children?: ReactNode; className?: string; contentClassName?: string }`, con `SectionCardTitle = Omit<SectionTitle, 'isHidden'>` — la regla «toda tarjeta lleva título visible», sostenida por el compilador | Los 22 usos de la `Card` cruda, donde 14 de 22 títulos eran un `<div>` y no un encabezado |
| **`PageIntro`** | `components/PageIntro.tsx` | `{ badge?: ReactNode; items?: ReactNode[]; className?: string }` | Las filas sueltas de badge + fecha, y todo intento de meter una fecha en `actions` |
| **`MetricCard`** | `components/MetricCard.tsx` | `{ label: string; value: ReactNode; denominator?: ReactNode; note?: string; fetchedAt?: string \| null; action?: ReactNode; tone?: 'default' \| 'unknown' }` | Las cuatro tarjetas de `section-cards.tsx`, las tres de la ficha de dominio, los dos bloques de `CoverageSummary` |
| **`LabelValue`** | `components/LabelValue.tsx` | `{ term: string; value: ReactNode; hint?: string; numeric?: boolean }` | Los pares etiqueta/valor dibujados a mano |
| **`DescriptionList`** | `components/LabelValue.tsx` | `{ items: LabelValueProps[]; columns?: 1 \| 2; className?: string }` | Las fichas de dominio, de lote y de credencial |
| **`CodeChip`** | `components/CodeChip.tsx` | `{ value: string; copy?: boolean; className?: string }` | Cinco `<code>` con **cuatro** juegos de clases distintos, sólo entre dos archivos |
| **`EmptyState`** | `components/EmptyState.tsx` | **La firma no cambia**: `{ icon, title, description, action }` | Su propio interior, que pasa a `@shadcn/empty`. El título sube a T4 y **es un encabezado real** |

**`StatusBadge` y los tres badges existentes.** `CoverageStateBadge`, `BatchStateBadge` y
`AccessStateBadge` **conservan su nombre, su firma pública, sus mapas de estado y sus docstrings**, y
pasan a **rendir a través de `StatusBadge`**. Ahí vive el conocimiento del dominio: por qué `PARTIAL`
tiene silueta propia, por qué `UNKNOWN` no es negativo, por qué `OTHER_NOT_INDEXED` tiene su icono.
Sus **diez consumidores no se enteran del cambio** — eso es lo que saca del camino crítico el cambio
de mayor alcance del rediseño.

Dos reglas que vienen con la pieza: **icono primero, palabra después** (RT-04, verificable en escala
de grises), y el `help` pasa de `title=` a `Tooltip` —`title` no se alcanza con el teclado ni existe
en táctil— **con la palabra siempre legible sin el tooltip**.

`tone` e `icon` son props **independientes**: un estado real que hoy no podemos confirmar se rinde
con `tone="unknown"` conservando el icono y la palabra del estado. Es lo que hace cumplir R-F sin
reescribir la etiqueta.

### Retoques a piezas que ya existen

| Pieza | Cambio | Por qué |
|---|---|---|
| `CopyButton` | Agregar `onCopy?: (success: boolean) => void` | Hoy traga el fallo del portapapeles en silencio: el icono no cambia y no hay forma de distinguir «no lo apretaste» de «no se pudo». En la revelación de una clave, eso es una clave perdida |
| `QuotaMeter` | La barra baja de 300 ms a 200 ms | Techo del contrato |
| `Spinner` | Sólo dentro de un botón o un badge, con gerundio y `aria-busy`; nada por debajo de 300 ms | Un parpadeo es peor que la espera |

---

## Fase 1B · Las moléculas

Dependen de los átomos. **No se empiezan hasta que 1A esté en verde.**

| Componente | Props | Qué resuelve |
|---|---|---|
| **`RowActions`** | `{ label: string; actions: RowActionMap }` | El `DropdownMenu` de la fila, **al principio y junto a la casilla**. Juego cerrado de acciones (§3.7), lo de lectura arriba, `DropdownMenuSeparator`, lo destructivo **último** y siempre vía `ConfirmDestructive` |
| **`DetailSheet`** | `{ paramName: string; openKey: string \| null; title: string; description?: string; size?: 'sm' \| 'default' \| 'lg'; children: ReactNode; action?: ReactNode }` | El panel de detalle del producto, con lo abierto en la URL: una fila abre con su id (`?url=`, `?session=`) y el tablero abre un aspecto por su nombre (`?panel=coverage`). `Sheet` en `md+`, `Drawer` abajo — **es el mismo componente, no otra decisión** |

**El ancho del panel es una decisión de contenido, no de gusto.** La primitiva traía un solo ancho
—384 px— y alcanza para un mensaje corto y nada más: una dirección larga, una fila de badge +
descripción o una lista de dominios se cortan contra el borde. `ui/sheet.tsx` expone la escala como
`SheetSize` y `DetailSheet` la pasa:

| `size` | Ancho | Cuándo |
|---|---|---|
| `sm` | 384 px | Un puñado de pares etiqueta/valor |
| `default` | 576 px | El detalle típico: una ficha con su historial |
| `lg` | 768 px | Lo que no se puede angostar sin romperlo: listas de dominios, direcciones completas, cadenas de agente, cualquier fila de badge + texto |

Abajo de `sm` el tamaño no aplica: el panel se presenta como cajón y ocupa todo el ancho, que es lo
único razonable en un teléfono. Y el ancho **no reemplaza** a `wrap-anywhere` + `min-w-0` en el
contenido: con un hostname suficientemente largo ningún ancho alcanza, y el nombre del sitio es
justo el dato que la persona fue a leer.
| **`FormDialog`** | `{ param: string; title: string; description?: string; submitLabel: string; pendingLabel: string; children: ReactNode }` | El formulario corto que abre desde un parámetro de la dirección, **no se cierra al enviar**, y devuelve el error del servidor junto al campo con el foco puesto |
| **`DangerZone`** | `{ title: string; description?: string; children: ReactNode }` | `Section tone="destructive"` al pie, con un `Item` por acción. Saca lo destructivo del slot `actions` |
| **`NoticeItem`** | `{ badge?: ReactNode; title: ReactNode; description?: ReactNode; meta?: ReactNode; action?: ReactNode; menu?: ReactNode; tone?: 'default' \| 'muted' }` | La fila [badge][título con enlace][motivo][fecha][acción], hoy dibujada distinta en el tablero y en notificaciones **siendo el mismo objeto** |
| **`FilterBar`** | `{ children: ReactNode; appliedSummary?: string; clearHref?: string }` | La región de filtros en **una línea**, con la línea de auditoría y «Limpiar todo» |
| **`FilterToggleGroup`** | `{ label: string; param: string; table: TablePage<any>; allLabel: string; options: {value,label,count?}[]; clears?: string }` | Un eje de hasta seis opciones como `ToggleGroup` de **enlaces reales** (`ToggleGroupItem asChild` + `<Link>`) |
| **`FilterPopover`** | `{ label: string; value?: string; count?: number; children: ReactNode }` | Un eje plegado; el disparador muestra su valor puesto o su cantidad |
| **`FilterChips`** | `{ label: string; param: string; options: {value,label}[]; current: string; page: TablePage<unknown>; allLabel?: string }` | Los tres filtros de estado del producto, hoy tres implementaciones |

### `DataTable` — dos agregados, sin tocar el resto

La tabla **queda como está**: ya cumple el contrato punto por punto (servidor, estado en la URL,
`aria-sort`, encabezado fijo, esqueleto que conserva la altura, `caption` que nombra el filtro,
total anunciado por región viva). Sólo se le agrega:

- **`paramPrefix?: string`** y su contraparte `table_props(..., prefix=)` en el servidor, para que
  dos listas convivan en una página sin disputarse `page` y `sort`. Lo necesita Claves de API (dos
  listas del mismo objeto) y lo va a necesitar cualquier vista con dos tablas.
- **`columnHelper.numeric()`**, que fija `text-right tabular-nums` en la celda **y en el encabezado a
  la vez**. Es la única forma de que la regla de columnas numéricas no se rompa fila por fila.

Y dos reglas de uso que ya están en el contrato y hay que sostener en cada vista: **la fila nunca es
clickeable entera** —el detalle se abre desde un `<Link>` en la primera celda o desde `RowActions`—,
y la anatomía de la primera celda es **un `<Link>` y, debajo, como mucho un `StatusBadge`**.

---

## Fase 3 · Los compartidos de la rama del dominio

Se construyen **entre las dos olas de vistas**, porque los consumen cuatro vistas a la vez y hacerlos
dentro de una sola garantiza que las otras tres los redibujen distinto.

| Componente | Props | Qué resuelve |
|---|---|---|
| **`DomainTabs`** | `{ domainId: string; current: 'summary' \| 'coverage' \| 'sitemaps' \| 'batches' }` | Las cuatro caras del dominio. `Tabs` con `TabsTrigger asChild` + `<Link>`, **sin `TabsContent`**: la primitiva pone la forma, el enlace pone la semántica, el estado vive en el path. Sin cifras en los rótulos. Se lleva los ocho botones «Ver la …» repartidos por los encabezados |
| **`DomainIdentity`** | `{ domain: DomainProps; canOperate: boolean }` | Preset de `PageIntro`, **idéntico en las cuatro rutas**: `AccessStateBadge` + forma de la propiedad + `property_uri` en `CodeChip` + «Comprobado ‹fecha›». **Es la pieza que hace cumplir R-F**, que hoy no se cumple en Cobertura, Sitemaps ni Lotes |
| **`CoverageFigure`** | `{ coverage: Coverage \| null; variant: 'cell' \| 'metric' }` | Los mismos tres números dichos **igual** en la celda del listado, en la métrica de la ficha y en el resumen de cobertura. Cubre los tres casos: sin URLs descubiertas, con URLs y sin dato, con dato |

**`DomainTabs` no va en la ficha de un lote.** Un lote no es una cara de un dominio: es un objeto con
página propia al que se llega desde una notificación, desde Sitemaps y desde la API. Ésa es la línea
que mantiene coherente la decisión de rutas.

---

## De una o dos vistas

Se construyen dentro de la vista que los estrena, pero con nombre y firma fijados acá para que la
segunda vista los encuentre en vez de redibujarlos.

| Componente | Vistas | Qué es |
|---|---|---|
| `QuotaCostDialog` | V6, V7 | La confirmación de lo que gasta cupo, con `QuotaMeter` adentro. **Una sola pieza para las dos puertas al mismo endpoint** — hoy una confirma nombrando los dos bolsillos y la otra dispara en silencio. La cifra se escribe **«hasta N», nunca «N»**: la tarea elige las URLs cuando corre |
| `SecretRevealDialog` | V11 | «Este dato existe una sola vez», sobre `AlertDialog`. Modal de verdad, salida con compuerta de dos estados, `Escape` por la misma compuerta, empuja una entrada de historial y arma `beforeunload` mientras no se copió |
| `BatchIssues` | V10, V8 | Los dos bloques de un lote, separados por `item !== null` y **no** por `failed_items`: fallas reales contra notas de un lote que no falló nada |
| `CoverageStateList` | V7 | Los diez estados en las tres familias de RT-03, con cuenta y porcentaje sobre las que tienen dato; `UNKNOWN` aparte y **sin porcentaje**. Es a la vez el desglose y el filtro — hoy son el mismo dato dibujado dos veces a 400 px |
| `StepList` / `StepItem` | V3 | El acordeón de los siete pasos con `?step=`. Absorbe `StepProgress`, `StepPanel` y `GuideStep`, que hoy dibujan lo mismo con tres marcados |
| `ServiceAccountAddress`, `AccessiblePropertyList`, `KeyFileField`, `GoogleConsoleLink`, `TaskColumn` | V2, V3 | Las ocho piezas hoy duplicadas entre Configuración y el recorrido. `KeyFileField` es **el único componente que toca el archivo de clave**, así que RT-06 pasa a tener un solo sitio donde comprobarse |
| `PollingIndicator` | V8, V9, V10 | «Se está leyendo · se actualiza sola»: `Spinner` dentro de un badge. Hoy el sondeo recarga cada 5 s sin ninguna señal en pantalla |
| `BatchProvenance` | V6, V7, V9, V10 | «Del lote ‹id corto›» como `<Link>` con `HoverCard` de cuatro datos |
| `RunningBatchCard` | V6, V7, V8 | `Card size="sm"` con `BatchProgress`, **sólo** mientras el lote no es terminal. 0 px el resto del tiempo |
| `ExportAction` / `ExportStatusCard` | V7 | El botón con sus dos caminos según el umbral, y el rastro del pedido grande |
| `ConnectionSummary`, `DomainQuotaDialog` | V2, V6 | Absorben bloques y tarjetas anidadas de sus vistas |

---

## Lo que le pedimos al servidor

Cuatro cosas, ninguna inventada: las cuatro son un `count`, un id o un filtro sobre consultas que ya
se hacen.

| Qué | Dónde | Para qué |
|---|---|---|
| `notifications.unread_action` | `apps/core/inertia.py` | Que el contador del menú y la primera línea de la vista digan lo mismo, sin derivarlo de una muestra de 50 filas |
| `domains.only_id` | `apps/web/dashboard.py` | Que con un solo dominio el botón lleve a *ese* dominio en vez de mandar al listado a elegir entre uno |
| Filtro `q` sobre `hostname` | `apps/domains/views.py` | A 300 dominios, encontrar uno es ordenar por nombre y caminar seis páginas |
| `table_props(prefix=)` | El helper de tablas | Que dos listas en una página no se peleen `page` y `sort` |
