feat: add PickerHelp component and browser fixture for picker UI

- Introduced PickerHelp component for contextual help in pickers.
- Created PickerBrowserFixture for testing picker components in a browser-like environment.
- Implemented a comprehensive regression test suite for picker UI using Playwright.
- Added support for localization in picker components and tests.
This commit is contained in:
David Arranz 2026-10-22 09:35:02 +02:00
parent f9384c9376
commit db57346c24
42 changed files with 1730 additions and 81 deletions

View File

@ -319,6 +319,13 @@ El diseño visual y de interacción sigue [el patrón de diálogos](docs/patron-
- Cada corrección de un bug reproducible debe considerar una prueba que falle antes de la corrección y pase después.
- Para lógica con estados cerrados, cubrir los casos relevantes y los límites, no solamente el camino feliz.
#### Volumen de datos de prueba
- En las pruebas funcionales, de integración de listados y auditorías visuales, utilizar un conjunto amplio de datos sintéticos: al menos 15 compañías y una compañía principal de pruebas con al menos 50 personas, 50 activos y 50 recursos. Extender este volumen a puestos de trabajo, perfiles de usuario y otras entidades relevantes para el flujo, respetando sus restricciones de dominio.
- Mantener relaciones coherentes y variedad representativa: nombres largos y con tildes, distintos estados, plantillas, sedes y asociaciones. Aprovechar el volumen para comprobar búsqueda, filtros, ordenación, paginación, selección y conservación del contexto; utilizar las demás compañías para verificar el aislamiento de sus datos.
- Preparar estos datos mediante procedimientos reproducibles en un entorno aislado y desechable, exclusivamente sobre `metis_desk_test`, siguiendo el procedimiento de Playwright cuando corresponda. No poblar ni modificar bases de datos reales para cumplir esta regla.
- Conservar las pruebas unitarias pequeñas y los escenarios específicos de vacío, uno y pocos registros como complemento. Informar del volumen realmente utilizado y de cualquier escenario de volumen pendiente; no declarar comprobaciones con datos abundantes si no se han ejecutado.
### Criterio antes de escribir código
Antes de implementar:

View File

@ -195,6 +195,12 @@ Fuente normativa: [patrón de diálogos](docs/patron-de-dialogos.md). Cada inter
| FormHelp | Hermano de FormBody antes de acciones, junto al pie; scroll propio y límite actual del 40 %. Su densidad en ventana corta requiere diseño adicional, V04. ContextHelp dentro del cuerpo no garantiza ese anclaje. |
| FormProgress | Paso actual, completados y anuncio con foco; falta ajustar etiquetas estrechas, V04. No simular navegación clicable si los pasos no la ofrecen. |
Capacidad operativa de selectores: [L02.1](docs/selectores-accesibilidad-l02-1.md) corrige
el colapso de filas, protege identidad y separa entidad/acción en los rangos. Comprobar
superficie visible además de altura exterior; conservar contenido extenso mediante scroll.
La compactación de herramientas y `PickerHelp` se limitan a esas composiciones. No resuelve
la densidad de listados generales, V08, V12 ni el proyecto V04.
Etiquetas con `htmlFor`, obligatoriedad según contrato y un único indicador de `FormLabel`; campos optativos sin sufijo. Títulos de grupo uniformes; título y contador en fila usan `layout="inline"` e `items-center`. Secciones adaptadas a su contenedor; detalles de columnas, reordenación accesible y numeración pertenecen al patrón especializado.
Una fuente de verdad por campo; React Hook Form y schemas Zod según patrones existentes. Errores junto al campo o como error global según su origen, con asociación accesible para mensajes propios; no cerrar ni limpiar antes de confirmar guardado. La ayuda no rellena campos automáticamente; propuestas de nombre o selecciones únicas solo donde el contrato específico ya lo establece.

View File

@ -0,0 +1,725 @@
# L01 — Auditoría transversal de listados de Metis Desk
Fecha: 22 de octubre de 2026. Fase diagnóstica, sin cambios de implementación. Las propuestas de este documento **no son nuevas reglas vigentes** de DESIGN.md.
**Actualización posterior — L02.1:** se implementaron y comprobaron la capacidad operativa
de los selectores locales de campos/opciones/activos (parte selectores de L01-01), identidad
mínima (L01-02) y microcopy (L01-04). Véanse [alcance y evidencias de L02.1](selectores-accesibilidad-l02-1.md).
Las medidas, capturas y clasificación originales de esta auditoría se conservan como
diagnóstico histórico. La densidad de listados generales bajo L01-01 sigue pendiente;
V05/V07/V08/V12 y los demás hallazgos mantienen su estado. No se declara universal el
objetivo de dos filas completas para opciones de longitud arbitraria.
Lectura: [inventario](#3-inventario-exhaustivo-de-listados), [cobertura visual](#6-evaluación-visual), [hallazgos](#13-hallazgos-priorizados), [patrón propuesto](#14-propuesta-de-patrón-objetivo), [wireframes](#15-alternativas-y-wireframes), [plan](#16-plan-l02l04) y [evidencias/limitaciones](#18-evidencias-y-limitaciones).
## 1. Resumen ejecutivo
Metis Desk ya dispone de una base común adecuada: `EntityListHeader`, `EntityTable`, TanStack Table, acciones principales visibles, columnas configurables y selectores con búsqueda y selección accesible. Conviene evolucionar esa base, no sustituirla. Las diferencias entre inventarios, configuraciones, selectores locales, consultas por cursor e historiales responden en buena parte al dominio.
La revisión combina código de 381 archivos TSX activos, navegación autenticada con Playwright MCP y 161 mediciones DOM: 23 listados o variantes en siete resoluciones. Se reutilizó la instancia sintética existente, sin crear usuarios, iniciar otro preparador ni escribir datos de negocio. Para disponer de paginación y relaciones representativas se sustituyeron exclusivamente cuatro respuestas GET en el navegador, con DTO sintéticos validados contra los schemas reales.
Los problemas anteriores **V05, V07, V08 y V12 siguen abiertos**: se pierde ordenación/página al volver de una ficha; buscar sin tildes no encuentra nombres acentuados; el inventario ofrece 75 columnas en una lista plana; la carga inicial desmonta herramientas y tabla. Se aportan reproducciones y se delimitan sus variantes.
Los nuevos hallazgos más relevantes son la escasa capacidad inicial para datos en móvil, especialmente dentro del selector de campos; la posibilidad de ocultar todas las columnas identificativas de `OptionPickerList`; y la limpieza incompleta de filtros en Sedes. No se ha demostrado un problema de rendimiento de producción. La descarga completa de inventarios para contadores sí exige una medición futura con más volumen.
Recomendación: L02 para capacidades comunes y composición, L03 para conservación del contexto y búsqueda, L04 para adopción y actualización normativa. Conservar el shell y los contratos de V01, la identidad visual, las primitivas vendor y las excepciones locales/remotas.
## 2. Alcance y método
### Fuentes y límites
Se consultaron [AGENTS.md](../AGENTS.md), [DESIGN.md](../DESIGN.md), la [matriz de skills](skills-frontend-ui-ux.md), el [patrón de listados](patron-de-listados-de-entidades.md), los [componentes de interfaz](componentes-de-interfaz.md), la [auditoría visual anterior](auditoria-ui-ux-visual.md), la [propuesta V01](propuesta-navegacion-responsive-v01.md), [V01.2](navegacion-responsive-v01-2.md), [V01.3](inventario-responsive-v01-3.md), [V01.4](recursos-responsive-v01-4.md), [Playwright MCP](playwright-mcp.md) y las [rutas del cliente](rutas-del-cliente.md). Las rutas se contrastaron con el código activo; no se presupone que cada entidad tenga ficha independiente.
El preflight confirmó repositorio limpio, ejecución en WSL/bash, Node 24.20.0 y pnpm 11.25.0. Se excluyeron `archive/`, tests y vendor del inventario de pantallas. Se consultaron las APIs públicas necesarias de componentes sin modificar `packages/shadcn-ui`.
La exploración AST no se limitó a nombres List/Table: se revisaron JSX tabular, `ul`, `ol`, timelines, `.map`, wrappers, importaciones, controladores, fuentes de consulta y consumidores. Resultado: **381 TSX**, **160 candidatos con colecciones**, **78 archivos con composiciones directas**, de los que **70 pertenecen a módulos** y ocho a componentes compartidos. Son archivos, no 78 pantallas distintas: un wrapper y su tabla no cuentan como dos listados funcionales.
### Entorno y datos
Se reutilizó la sesión privada preparada por `scripts/visual-audit.py` y la imagen Docker MCP ya configurada en `.codex/config.toml`. No se instaló Chromium en WSL. La instancia tenía una compañía sintética, un activo y catálogos; carecía de recursos y puestos suficientes para los escenarios solicitados. No se accedió a Abeto Design ni a datos reales.
La instrumentación efímera añadió en lectura 30 activos, 25 personas, 25 puestos, 25 perfiles, 25 recursos, cinco sedes, cinco departamentos y asignaciones coherentes. No todos aparecen simultáneamente: los filtros de actividad ocultan registros. Los payloads se validaron con `CompanyResponseSchema`, `AssetInventoryResponseSchema`, `ResourceInventoryResponseSchema` y `WorkProfilesResponseSchema`. Se conservaron IDs del catálogo real **del entorno sintético**. Las relaciones complejas de SMB, dominios, accesos e identidades no se inventaron.
Solo se interceptaron GET de compañía, inventario de activos, recursos y perfiles de esa compañía. Se bloquearon en el navegador métodos de escritura; no hubo altas, guardados, borrados ni cambios de estado. Las fichas que necesitan endpoints adicionales se recorrieron con el activo original existente o se declararon pendientes. El almacenamiento local de preferencias de ese navegador sí se ejercitó.
### Cómo interpretar las conclusiones
- **V+F:** captura y comportamiento reproducidos; **DOM:** medición del navegador; **C:** código inspeccionado; **P:** pendiente por datos/permisos. Una medición de todas las resoluciones no equivale a una captura de todas ellas.
- Las capturas se tomaron tras fuentes, datos y transiciones; se midieron rectángulos, overflow, filas, controles y foco. Las demoras de V12 utilizaron una compuerta de respuesta, no esperas arbitrarias.
- Las respuestas 503 y la desaparición de una selección fueron **simulaciones en el navegador**. El 403 de Usuarios fue una respuesta real del entorno con usuario ordinario.
- No se acredita conformidad WCAG global, lector de pantalla, contraste instrumental completo, rendimiento de producción ni navegadores distintos de Chromium headless.
## 3. Inventario exhaustivo de listados
### Contratos comunes usados en el inventario
Las filas siguientes heredan explícitamente estos atributos para evitar repetir el mismo contrato en cada consumidor. Las diferencias se indican en cada familia. `:c` representa el identificador de compañía; las rutas no son URLs de producción.
| Perfil | Presentación, búsqueda, ordenación y paginación | Columnas, selección, estados, persistencia y responsive |
| --- | --- | --- |
| T | `EntityTable` local. Búsqueda inmediata; si el módulo suministra búsqueda/filtrado, manda ese contrato; en otro caso, filtro global TanStack sobre `value`, con trim. Nombre usa `Intl.Collator(locale, { sensitivity: 'base', numeric: true })`; otras columnas comparables y fallback según definición. Página local 10/25/50/100, ajuste al filtrar. | Columnas ocultables salvo `hideable:false`, orden definido por consumidor, sin reordenación de columnas. Sin selección masiva por defecto; clic de fila solo si se aporta callback. Carga/error dependen también del padre. Vacío distingue criterios activos. Tamaño de página en `entity-page-size`; visibilidad solo si se configura clave. Sort/página no sobreviven al desmontaje. Tabla con scroll horizontal interno y acciones ancladas; no se transforma automáticamente en tarjetas. |
| TL | T dentro de selector local, `layout="fill"`. Búsqueda rápida con foco inicial y cuerpo desplazable; paginación local. | Selección primera, radio o checkbox según consumidor, acción de confirmación y cancelación en diálogo propio. Altura estable. Selección provisional salvo elección directa explícita. No persistir la selección como preferencia. |
| R | Consulta remota por cursor/página del controlador; búsqueda/orden del servidor o proveedor. `CursorPagination`/controles remotos, sin filtrar ni repaginar localmente una página recibida. | No se heredan selector de columnas ni selección masiva de T. Vacío, carga, error y reintento según proveedor; IDs y restricciones aportados por módulo. Cuerpo con scroll interno y foco al abrir cuando sea picker. |
| S | Lista semántica, tarjetas, campos agrupados o timeline. Sin buscador, columnas, ordenación interactiva ni paginación, salvo indicación. | Orden de dominio, cronología o posición configurada; acciones explícitas por elemento cuando existen. Estado y permisos del padre/controlador. Sin preferencias tabulares. Se adapta por wrap/grid/scroll del contenedor. |
Fuentes compartidas: [EntityTable/Header](../packages/rdx-ui/src/components/entity-list.tsx), [contenido](../packages/rdx-ui/src/components/entity-table-content.tsx), [hook](../packages/rdx-ui/src/hooks/use-entity-table.ts), [columnas](../packages/rdx-ui/src/components/entity-table-columns-dialog.tsx), [almacenamiento](../packages/rdx-ui/src/lib/entity-column-visibility-storage.ts), [OptionPickerList](../packages/rdx-ui/src/components/option-picker-list.tsx), [CursorPagination](../packages/rdx-ui/src/components/cursor-pagination.tsx), [RemoteOptionPicker](../packages/rdx-ui/src/components/remote-option-picker.tsx) y [RemoteOptionList](../packages/rdx-ui/src/components/remote-option-list.tsx).
### A. Listados principales
Los componentes enlazados en el registro de código posterior permiten localizar cada familia sin depender del artefacto temporal. Los controladores homónimos `use-…-controller` viven en la operación `list/controllers`, salvo los casos indicados. La tabla no efectúa consultas: sus padres y controladores aportan datos.
| ID / nombre / módulo | Ruta, componente y responsable de datos | Búsqueda y filtros propios | Contrato de interacción, estados y preferencias |
| --- | --- | --- | --- |
| A01 Compañías · companies | `/companies`; `CompaniesPage`, `useCompaniesPageController`, consulta de compañías. | Nombre, solo activas; `q`, `activeOnly`, selección de compañía en URL. El controlador ya filtra con TanStack antes de T. | T; nombre abre compañía, acción principal de abrir y menú de gestión. Alta/edición modal. No selección masiva. Carga/error externos. |
| A02 Activos · assets | `/companies/:c/assets`; `AssetInventoryPage` → `AssetList`, `useInventoryPageController`; inventario + catálogo + infraestructura. | Nombre, plantilla, estado y sede; pestaña de plantilla y `q` en URL. No búsqueda universal por IP/serie. | T; clave `asset-inventory-columns`; identidad, tipo, puesto y acciones protegidos. Nombre abre ficha; fila abre contexto adaptativo. Editar visible; ver/eliminar en menú; crear/ver puesto en su columna protegida. Carga inicial condiciona el montaje. |
| A03 Recursos · resources | `/companies/:c/resources`; `ResourcesPage`, `useResourcesPageController`; inventario + catálogo. | Nombre y plantilla; `q` y plantilla en URL; no trim en filtro del módulo. | T; fila/nombre seleccionan recurso por ruta contextual, no imitar ficha de activo. Panel o diálogo según capacidad; acción explícita de ficha e historial. Editar visible, ciclo de vida secundario. Carga/error externos. |
| A04 Personas · companies | `/companies/:c/people`; `PeopleSection`; estado local y agregado de compañía + inventario para puestos responsables. | Nombre, solo contactos. Estado de sección, no URL. | T; abrir persona visible, nombre a detalle, creación de puesto desde persona. Listas de departamentos y puestos enlazados dentro de celdas. Búsqueda/contactos sobreviven al detalle mientras permanece el padre, sort no. |
| A05 Puestos · assets | `/companies/:c/workstations`; `WorkstationsPage`, `useWorkstationsPageController`; inventario + lookups compañía. | Nombre, sede y responsable; estado local del controlador. | T; nombre a ficha, editar principal, ciclo de vida en menú. Activos/persona son enlaces. Sin selección masiva. Estados de consulta exteriores. |
| A06 Perfiles · companies | `/companies/:c/work-profiles`; `WorkProfilesPage`, `useWorkProfilesPageController`; perfiles + compañía/inventario/accesos relacionados. | Nombre, persona y ordenador; archivados, `q` y filtro en URL. | T; nombre abre entorno/configuraciones del perfil existente, no una ficha independiente inventada. Editar visible, historial y ciclo de vida propios. Carga requiere referencias. |
| A07 Sedes · companies | `/companies/:c/sites`; `SiteSection`, `useSiteSectionController`; sedes del agregado de compañía. | Búsqueda genérica por valores de columnas y mostrar archivadas; checkbox local. | T; editar modal, activar/archivar y borrar según asociaciones. `Limpiar filtros` solo limpia búsqueda: discrepancia documentada. No ficha completa obligatoria. |
| A08 Departamentos · companies | `/companies/:c/departments`; `DepartmentSection`; agregado compañía y estado local. | Filtro genérico T sobre valores de columnas, sin filtro específico adicional. | T; nombre informativo, editar/modal y acciones de estado. No convertir en enlace a ficha inexistente. |
| A09 Infraestructura · assets | `/companies/:c/infrastructure`; `InfrastructurePage`; consultas de inventario e infraestructura. | Texto y roles/criterios definidos en la pantalla; composición T local. | T; configuración de interfaces y relaciones en editor específico; enlaces a activos. DOM vacío recorrido; población de infraestructura y datos técnicos extensos pendientes. |
| A10 Teletrabajo · companies | `/companies/:c/telework-authorizations`; `TeleworkAuthorizationsPage`, controlador homónimo; autorizaciones + compañía. | Persona y restricción por persona inicial cuando existe; sin filtro adicional de cerradas. | T; alta/editar e historial; la vigencia se refleja en columnas y formulario. Tabla tras disponibilidad de datos; no simular autorizaciones. |
| A11 Accesos remotos · companies | `/companies/:c/remote-connections`; `RemoteConnectionsPage`, controlador homónimo; conexiones + compañía/inventario/recursos. | Persona, protocolo, extremos; cerradas. | T; editar e historial; filtro local de cerradas. Extremos son datos de dominio, no texto técnico universal buscable en todos los listados. |
| A12 Procesos · companies | `/companies/:c/processes`; `ProcessesPage`, `useProcessesPageController`; procesos + plantillas/personas. | Nombre, persona, tipo y cerrados; filtro de cerrados serializado. | T; abrir detalle de tareas, editar/transiciones según proceso. Detalle/alta son colecciones S, no segunda tabla. |
| A13 Carpetas de perfil · companies | `/companies/:c/folder-mappings`; `FolderMappingsPage`, controlador homónimo; mapeos + perfiles + necesidades/recursos. | Ruta, perfil y carpeta/necesidad; restricción por perfil inicial, sin filtro adicional de cerradas. | T; editar/historial/eliminación con diálogo propio, enlaces contextuales. Datos derivados de relaciones, no duplicar en rdx-ui. |
| A14 Correo de perfil · companies | `/companies/:c/mail-configurations`; `MailConfigurationsPage`, controlador homónimo; configuraciones + buzones/perfiles/personas. | Dirección y destino; restricción por perfil inicial, sin filtro adicional de cerradas. | T; editar/historial/eliminación con diálogo propio. Buzón y configuración son entidades diferentes. |
| A15 Certificados de perfil · companies | `/companies/:c/certificate-configurations`; `CertificateConfigurationsPage`, controlador homónimo; configuraciones + recursos/perfiles. | Recurso y etiqueta de perfil; restricción por perfil inicial, sin filtro adicional de cerradas. | T; editar/historial/eliminación con diálogo propio. Consulta compuesta antes de presentar tabla. |
| A16 Aplicaciones de perfil · companies | `/companies/:c/application-configurations`; `ApplicationConfigurationsPage`, controlador homónimo; aplicaciones/configuraciones + recursos/perfiles. | Recurso y etiqueta de perfil; restricción por perfil inicial, sin filtro adicional de cerradas. | T; editar/historial/eliminación con diálogo propio; instalación no equivale a recurso. |
| A17 Impresoras de perfil · companies | `/companies/:c/printer-configurations`; `PrinterConfigurationsPage`, controlador homónimo; configuración + impresoras/perfiles. | Nombre de activo y etiqueta de perfil; restricción por perfil inicial, sin filtro adicional de cerradas. | T; editar/historial/eliminación con diálogo propio. Equipo y configuración mantienen restricciones propias. |
| A18 Cuentas y accesos · companies | `/companies/:c/access?kind=…`; `AccessWorkspacePage`, `useAccessWorkspacePageController`; API de accesos + referencias del ámbito. | Búsqueda local y navegación por `kind`; filtros/estado según categoría. | T con columnas por categoría; formularios, historial, cierre/activación según tipo. Cobertura y herencia no son registros editables equivalentes a cuentas. Datos poblados de relaciones pendientes. |
A18 comprende realmente `account`, `account_person`, `group`, `membership`, `grant`, `need`, `coverage`, `environment`, `system`, `resource_type` y `permission`. No son once componentes de tabla duplicados. Se inspeccionaron visualmente account, permission y environment vacíos; las restantes variantes se contrastaron en código.
### D. Catálogos y configuraciones administrables
| ID / nombre | Ruta/contexto y responsable | Datos, consulta y diferencias respecto a T |
| --- | --- | --- |
| D01 Catálogo de activos · assets | `/catalogs/assets`, `AssetCatalogPage`, `useAssetCatalogPageController`; también catálogo del ámbito compañía. | T por pestaña `templates`, `fields`, `relations`; API de catálogo global/compañía con estado efectivo. Búsqueda por nombre y pestaña en URL; no hay un filtro adicional de disponibilidad en el controlador. Alta/editar modal; retirar/restaurar según uso. Nombre no implica ficha. Plantillas ordenan secciones/campos con editor propio. |
| D02 Catálogo de recursos · resources | `/catalogs/resources`, `ResourceCatalogPage`, `useResourceCatalogPageController`; ámbito compañía. | T por plantillas/campos; catálogo compartido más ámbito, uso de plantilla y campos efectivos. Editor modal. Contrato de herencia/retirada legítimo; no borrar snapshots con valor. |
| D03 Marcas · assets | `/catalogs/brands`; `BrandCatalogPage`, `useBrandCatalogPageController`, API de marcas. | T; búsqueda y estados del catálogo; edición/alta modal y restricciones de uso. No ficha de marca independiente. |
| D04 Plantillas de proceso · companies | `/catalogs/process-templates`; `ProcessTemplatesPage`, controlador homónimo, API de plantillas. | T; nombre/tipo/actividad y editor con tareas ordenadas S. Alta/editar/estado. |
| D05 Accesos globales · companies | `/catalogs/access?kind=…`; mismo workspace/controlador A18, ámbito global. | T; `system`, `resource_type`, `permission`, `template`. Campos, permisos y protección de elementos base dependen del ámbito; no forzar acciones de compañía. |
| D06 Usuarios · identity | `/settings/users`; `UserManagement`, `useUserManagementController`, API de usuarios. | T en código con búsqueda genérica T, columnas de roles/actividad y formularios propios. La sesión ordinaria obtiene 403 y mensaje de recuperación; **tabla poblada, filtros y responsive administrativos pendientes de permiso**. No se creó administrador. |
### B. Sublistados y relaciones
| ID / colección | Contexto, componentes/control y origen | Contrato y atributos específicos |
| --- | --- | --- |
| B01 Asignaciones de persona | Ficha de persona, `PersonDetails`; agregado compañía, asignaciones con departamento/puesto/periodo. | T local; filtro genérico, fechas/estado principal comparables, editar/finalizar/historial según permiso. Sin selección masiva. Un registro poblado inspeccionado en 1440 y 390. |
| B02 Perfiles del puesto | Ficha de puesto, `WorkstationProfiles`, consulta de perfiles filtrada por puesto. | T local, nombre/persona; enlaces a perfil y persona; sin acciones de gestión duplicadas obligatorias. Un registro inspeccionado. |
| B03 Perfiles del activo | `AssetRelatedInformation`, ficha de activo dentro de compañía; perfiles y referencias. | T local derivada, navegación contextual; estados de consultas del padre. Contraste estático, no población completa de instalaciones. |
| B04 Instalaciones de software | `SoftwareInstallationsPanel`; activo/entorno, query propia de instalaciones y referencias. | T, búsqueda/columnas locales y filtro de cerradas, editar/historial/eliminar instalación; carga/error externos. Población pendiente. |
| B05 Pertenencias a directorio | Activo y recurso dominio: `DirectoryRelationships` → `DirectoryRelationshipTable`, proveedor de relaciones tipadas. | R + tabla simple; vigentes/históricas, cursor, resúmenes e identidad de origen/destino; finalizar/gestionar según elegibilidad. No T local ni búsqueda sobre una página remota. Sin persistencia de columnas. Población pendiente. |
| B06 Publicaciones SMB | Activo/servidor y recurso compartido: `PublicationList`, consultas de publicaciones y referencias. | R + `Table`; activas/históricas, ruta/host/periodo/actor y acciones propias. Sin paginación local superpuesta. Población y segunda página pendientes. |
| B07 Compañías del dashboard | `DashboardCompaniesPanel` → `DashboardTable`; consulta agregada y top cinco. | Tabla simple, no T; nombres enlazados y cifras. Vista resumida, sin buscador/paginador/columnas. Orden del agregado; acción de ampliar. Código inspeccionado. |
| B08 Detalles del dashboard | `DashboardDetailsDialog`, `useDashboardDetailsDialogController`, API de detalles/agregados. | Compañías usa T sobre **conjunto agregado completo**. Otras categorías usan página remota; actividad es snapshot cronológico. No se detecta doble paginación de la misma página. Búsqueda/columnas dependen de rama. Nombre de compañía ocultable: riesgo C relacionado con L01-02. |
| B09 Red y uso de impresora | `AssetInfrastructurePanel`, `AssetPrinterUsage`; interfaces/IPs y conexiones/consumidores del activo. | S con grupos de interfaces y relaciones; editores separados. IP/MAC no se truncan sin vía de consulta. Sin ordenación/paginación T. Datos técnicos poblados pendientes. |
| B10 Relaciones entre activos | `AssetRelationships`, controlador propio; entrada/salida y tipo de relación. | S, tarjetas/listas con enlaces, alta/finalización e historial según contrato. No selección tabular; estado de query y vacíos propios. |
| B11 Asistencia remota | `AssetSupport`, `useAssetSupportController`; conexiones de soporte del activo y recursos herramienta. | S; estado, acción de editar, historia y restricciones. No homogeneizar con conexiones entre extremos de A11. |
| B12 Buzón y usos | `MailboxRelations`, `useMailboxRelationsController`; personas/cuentas/usos del buzón. | S con acciones y enlaces contextuales, carga/error específicos. El recurso no contiene cinco tablas de accesos: `CompanyResourceRelatedContent` ofrece enlaces a cinco vistas filtradas. |
| B13 Identidades y entorno | `AssetLocalIdentities`, `EnvironmentSection`, `EnvironmentContent`; identidades locales, entorno y configuraciones del perfil. | S; grupos jerárquicos, permisos y acciones del módulo; no inventario T. Sin preferencias de columnas. Población relacional pendiente. |
| B14 Puestos, personas y activos asociados | `CurrentWorkstations`, `WorkstationAssets`, `WorkstationPeople`, resúmenes de ficha/panel de activo. | S; agrupar responsable/usuario y principal/periféricos, tarjetas/enlaces. No paginación de una pequeña relación acotada ni fila clicable que compita con enlaces. |
### C. Listados de selección
| ID / selector | Contexto y origen/controlador | Contrato |
| --- | --- | --- |
| C01 Activos | `AssetPickerDialog` → `AssetPickerList` → `AssetList`, `useAssetPickerController`; inventario + catálogo y exclusiones aportadas por consumidor. | TL múltiple o única según operación, primera columna compacta, búsqueda T del módulo, confirmar/cancelar/crear. Uso en puesto, relaciones, impresoras, extremos remotos e infraestructura; restricciones diferentes. Foco, vacío y cierre probados; no se guardó ninguna selección en servidor. |
| C02 Campos de plantilla | `CatalogFieldPickerList` de assets/resources → `FieldPickerList`; catálogo efectivo, controlador compartido de campos. | TL múltiple; nombre protegido, elegibilidad y destino de sección propios. Añadir/cancelar/nuevo campo. 55 campos reales del catálogo sintético; pérdida de capacidad visible en 320 × 640. |
| C03 Opciones de perfil | `OptionPickerDialog` → `OptionPickerList`; opciones de puesto/persona/cuenta construidas por formulario de perfil. | TL única con radio, provisional y confirmar. Búsqueda sobre etiqueta/descripción. `name` **no** protegido frente a ocultación; reproducido en selección de puesto. |
| C04 Dominio Windows | `DirectoryDomainPicker`; consulta remota de recursos dominio elegibles, búsqueda por nombre/DNS. | R + `ul` de botones; selección directa dentro del editor de pertenencia, sin confirmación adicional del picker. Foco y vacío remoto reales comprobados; no había dominios para recorrer cursor poblado. |
| C05 Publicación SMB | `PublicationPicker` → `RemoteOptionPicker`/`RemoteOptionList`; proveedor remoto elegible. | R con selección provisional, confirmar, errores/reintento y cursor. No envolver en filtro/paginación T. Solo C; requiere fixture de host, recurso y publicación válidos. |
| C06 Configuración visual | `EntityTableColumnsDialog` e `IconPicker`. | Checklist de columnas y rejilla de iconos: colecciones de configuración, no inventarios de entidades. Columnas aplica al pulsar checkbox; cerrar no cancela cambios. Iconos usa búsqueda/opciones propias, sin tabla. |
### E. Presentaciones especiales y límites del inventario
| ID | Colecciones reales y contexto | Tratamiento justificado |
| --- | --- | --- |
| E01 Historia de activos/puestos | `AssetHistory`, `WorkstationHistory`, `TimeLine`. | Cronología y revisiones, sin orden alfabético ni T. Actor/fecha/cambios; carga del contenedor. |
| E02 Historia de recursos y configuraciones | `ResourceDetail`, historias de perfil, asignación, acceso, soporte, software, teletrabajo, conexión remota, correo, carpeta, certificado, aplicación e impresora. | `ol`, `TimeLine` o grupos de revisiones; orden cronológico del dominio, modales independientes. No paginación inventada. Casos poblados de todas las revisiones pendientes. |
| E03 Edición y resumen de selección | `WorkstationAssetFields`, `WorkstationComputerField`, `WorkstationSummary`, `WorkProfileComputerSummary`, `WorkProfileSummary`. | UL/tarjetas, elección visual del principal y eliminación contextual. Orden y acciones de formulario, no inventario gestionable. EmptyState y ayuda contextual propios; no duplicar principal en resumen de periféricos. |
| E04 Plantillas, secciones y campos | `TemplateSectionsEditor`, `TemplateSectionFields`, `FieldSections`, `TabbedFieldSections`, formularios de plantillas de ambos módulos y panel de especificación del activo. | Posición y agrupación configuradas, plegado/reordenación accesible. No orden alfabético que destruya orden del dominio. En panel, campos con valor; campos retirados son snapshots conservados. |
| E05 Procesos y tareas | `ProcessDetail`, `ProcessStartForm`, `ProcessTemplateForm`, `ProcessEditForm`. | Pasos/tareas/revisiones ordenados; estados y acciones por tarea. No son filas de proceso independientes. |
| E06 Resúmenes y navegación | `DashboardActivityPanel`, `DashboardAttentionPanel`, `CatalogOverview`, estadísticas de compañía/dashboard y `WorkspaceSearchContent`. | Cronología, alertas, tarjetas y resultados de navegación local. Sin selector de columnas ni paginación artificial. Búsqueda global del workspace filtra compañías/destinos, no es buscador federado de todas las entidades. |
| E07 Elecciones y controles de formulario | `SelectWithAction`, permisos, recursos/cuentas/personas/sedes de formularios, opciones de tipo/estado/idioma. | Select/combobox o controles de opción de formulario. Valores cerrados o referencias acotadas; no pantallas T. Si crecen, valorar picker con contrato propio; no diagnosticar falta de paginación en un enum. |
### Registro verificable de composiciones directas
Este registro cubre los 70 archivos de módulos detectados directamente por JSX de colección. Las tablas A–E añaden los wrappers y colecciones por `.map` que no aparecen en ese primer grupo. Las listas dentro de una celda —por ejemplo IPs, activos o departamentos— son presentación de valores, no un nuevo listado principal.
| Familia | Componente / fichero real | Composición directa |
| --- | --- | --- |
| A01 | [CompaniesPage — companies · companies/list/ui/companies-page.tsx](../modules/companies/src/web/companies/list/ui/companies-page.tsx) | EntityTable |
| A02 | [AssetInventoryPage — assets · assets/list/ui/inventory-page.tsx](../modules/assets/src/web/assets/list/ui/inventory-page.tsx) | AssetList |
| A02 | [assetAdditionalColumns — assets · assets/shared/helpers/asset-additional-columns.tsx](../modules/assets/src/web/assets/shared/helpers/asset-additional-columns.tsx) | ul |
| A02 | [AssetList — assets · assets/shared/ui/asset-list.tsx](../modules/assets/src/web/assets/shared/ui/asset-list.tsx) | EntityTable |
| A03 | [ResourcesPage — resources · resources/list/ui/resources-page.tsx](../modules/resources/src/web/resources/list/ui/resources-page.tsx) | EntityTable |
| A04 | [PeopleSection — companies · people/list/ui/people-section.tsx](../modules/companies/src/web/people/list/ui/people-section.tsx) | EntityTable, ul |
| A05 | [WorkstationsPage — assets · workstations/list/ui/workstations-page.tsx](../modules/assets/src/web/workstations/list/ui/workstations-page.tsx) | EntityTable, ul |
| A06 | [WorkProfilesPage — companies · work-profiles/list/ui/work-profiles-page.tsx](../modules/companies/src/web/work-profiles/list/ui/work-profiles-page.tsx) | EntityTable |
| A07 | [SiteSection — companies · sites/list/ui/site-section.tsx](../modules/companies/src/web/sites/list/ui/site-section.tsx) | EntityTable |
| A08 | [DepartmentSection — companies · departments/list/ui/department-section.tsx](../modules/companies/src/web/departments/list/ui/department-section.tsx) | EntityTable |
| A09 | [InfrastructurePage — assets · infrastructure/list/ui/infrastructure-page.tsx](../modules/assets/src/web/infrastructure/list/ui/infrastructure-page.tsx) | EntityTable, ul |
| A10 | [TeleworkAuthorizationsPage — companies · telework-authorizations/list/ui/telework-authorizations-page.tsx](../modules/companies/src/web/telework-authorizations/list/ui/telework-authorizations-page.tsx) | EntityTable |
| A11 | [RemoteConnectionsPage — companies · remote-connections/list/ui/remote-connections-page.tsx](../modules/companies/src/web/remote-connections/list/ui/remote-connections-page.tsx) | EntityTable |
| A12 | [ProcessesPage — companies · processes/list/ui/processes-page.tsx](../modules/companies/src/web/processes/list/ui/processes-page.tsx) | EntityTable |
| A13 | [FolderMappingsPage — companies · folder-mappings/list/ui/folder-mappings-page.tsx](../modules/companies/src/web/folder-mappings/list/ui/folder-mappings-page.tsx) | EntityTable |
| A14 | [MailConfigurationsPage — companies · mail-configurations/list/ui/mail-configurations-page.tsx](../modules/companies/src/web/mail-configurations/list/ui/mail-configurations-page.tsx) | EntityTable |
| A15 | [CertificateConfigurationsPage — companies · certificate-configurations/list/ui/certificate-configurations-page.tsx](../modules/companies/src/web/certificate-configurations/list/ui/certificate-configurations-page.tsx) | EntityTable |
| A16 | [ApplicationConfigurationsPage — companies · application-configurations/list/ui/application-configurations-page.tsx](../modules/companies/src/web/application-configurations/list/ui/application-configurations-page.tsx) | EntityTable |
| A17 | [PrinterConfigurationsPage — companies · printer-configurations/list/ui/printer-configurations-page.tsx](../modules/companies/src/web/printer-configurations/list/ui/printer-configurations-page.tsx) | EntityTable |
| A18 | [AccessWorkspacePage — companies · access/list/ui/access-workspace.tsx](../modules/companies/src/web/access/list/ui/access-workspace.tsx) | EntityTable |
| B01 | [PersonDetails — companies · people/detail/ui/person-details.tsx](../modules/companies/src/web/people/detail/ui/person-details.tsx) | EntityTable |
| B02 | [WorkstationProfiles — companies · workstations/detail/ui/workstation-profiles.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx) | EntityTable |
| B03 | [AssetRelatedInformation — companies · assets/detail/ui/asset-related-information.tsx](../modules/companies/src/web/assets/detail/ui/asset-related-information.tsx) | EntityTable |
| B04 | [SoftwareInstallationsPanel — companies · software-installations/list/ui/software-installations-panel.tsx](../modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx) | EntityTable |
| B05 | [DirectoryRelationshipTable — assets · directory-memberships/shared/ui/directory-relationship-table.tsx](../modules/assets/src/web/directory-memberships/shared/ui/directory-relationship-table.tsx) | Table |
| B05 | [DirectoryRelationships — assets · directory-memberships/shared/ui/directory-relationships.tsx](../modules/assets/src/web/directory-memberships/shared/ui/directory-relationships.tsx) | ul |
| B06 | [PublicationList — assets · network-share-publications/shared/ui/publication-list.tsx](../modules/assets/src/web/network-share-publications/shared/ui/publication-list.tsx) | Table |
| B07 | [DashboardTable — companies · dashboard/shared/ui/dashboard-table.tsx](../modules/companies/src/web/dashboard/shared/ui/dashboard-table.tsx) | Table |
| B08 | [DashboardDetailsDialog — companies · dashboard/detail/ui/dashboard-details-dialog.tsx](../modules/companies/src/web/dashboard/detail/ui/dashboard-details-dialog.tsx) | EntityTable |
| B09 | [AssetInfrastructurePanel — assets · infrastructure/shared/ui/asset-infrastructure-panel.tsx](../modules/assets/src/web/infrastructure/shared/ui/asset-infrastructure-panel.tsx) | ol, ul |
| B09 | [AssetPrinterUsage — companies · infrastructure/shared/ui/asset-printer-usage.tsx](../modules/companies/src/web/infrastructure/shared/ui/asset-printer-usage.tsx) | ul |
| B10 | [AssetRelationships — assets · asset-relationships/list/ui/asset-relationships.tsx](../modules/assets/src/web/asset-relationships/list/ui/asset-relationships.tsx) | ul |
| B11 | [AssetSupport — assets · support-connections/list/ui/asset-support.tsx](../modules/assets/src/web/support-connections/list/ui/asset-support.tsx) | ul |
| B12 | [MailboxRelations — companies · mailboxes/shared/ui/mailbox-relations.tsx](../modules/companies/src/web/mailboxes/shared/ui/mailbox-relations.tsx) | ul |
| B13 | [AssetLocalIdentities — companies · local-identity-environments/detail/ui/asset-local-identities.tsx](../modules/companies/src/web/local-identity-environments/detail/ui/asset-local-identities.tsx) | ul |
| B13 | [EnvironmentContent — companies · work-environments/detail/ui/environment-content.tsx](../modules/companies/src/web/work-environments/detail/ui/environment-content.tsx) | ul |
| B13 | [EnvironmentSection — companies · work-environments/detail/ui/environment-section.tsx](../modules/companies/src/web/work-environments/detail/ui/environment-section.tsx) | ul |
| B14 | [WorkstationAssets — companies · workstations/detail/ui/workstation-assets.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx) | ul |
| B14 | [WorkstationPeople — companies · workstations/detail/ui/workstation-people.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-people.tsx) | ul |
| C01 | [AssetPickerList — assets · assets/select/ui/asset-picker-list.tsx](../modules/assets/src/web/assets/select/ui/asset-picker-list.tsx) | AssetList |
| C02 | [CatalogFieldPickerList — assets · catalog-fields/select/ui/catalog-field-picker-list.tsx](../modules/assets/src/web/catalog-fields/select/ui/catalog-field-picker-list.tsx) | FieldPickerList |
| C02 | [CatalogFieldPickerList — resources · catalog-fields/select/ui/catalog-field-picker-list.tsx](../modules/resources/src/web/catalog-fields/select/ui/catalog-field-picker-list.tsx) | FieldPickerList |
| C04 | [DirectoryDomainPicker — assets · directory-memberships/select/ui/directory-domain-picker.tsx](../modules/assets/src/web/directory-memberships/select/ui/directory-domain-picker.tsx) | ul |
| C05 | [PublicationPicker — assets · network-share-publications/select/ui/publication-picker.tsx](../modules/assets/src/web/network-share-publications/select/ui/publication-picker.tsx) | RemoteOptionPicker |
| D01 | [AssetCatalogPage — assets · catalog/list/ui/catalog-page.tsx](../modules/assets/src/web/catalog/list/ui/catalog-page.tsx) | EntityTable |
| D02 | [ResourceCatalogPage — resources · catalog/list/ui/catalog-page.tsx](../modules/resources/src/web/catalog/list/ui/catalog-page.tsx) | EntityTable |
| D03 | [BrandCatalogPage — assets · brands/list/ui/brands-page.tsx](../modules/assets/src/web/brands/list/ui/brands-page.tsx) | EntityTable |
| D04 | [ProcessTemplatesPage — companies · process-templates/list/ui/process-templates-page.tsx](../modules/companies/src/web/process-templates/list/ui/process-templates-page.tsx) | EntityTable, ol |
| D06 | [UserManagement — identity · users/list/ui/user-management.tsx](../modules/identity/src/web/users/list/ui/user-management.tsx) | EntityTable |
| E01 | [AssetHistory — assets · assets/detail/ui/asset-history.tsx](../modules/assets/src/web/assets/detail/ui/asset-history.tsx) | TimeLine, ul |
| E01 | [WorkstationHistory — assets · workstations/history/ui/workstation-history.tsx](../modules/assets/src/web/workstations/history/ui/workstation-history.tsx) | TimeLine |
| E02 | [SupportHistoryDialog — assets · support-connections/history/ui/support-history-dialog.tsx](../modules/assets/src/web/support-connections/history/ui/support-history-dialog.tsx) | ol |
| E02 | [ApplicationHistoryDialog — companies · application-configurations/history/ui/application-history-dialog.tsx](../modules/companies/src/web/application-configurations/history/ui/application-history-dialog.tsx) | ol |
| E02 | [CertificateHistoryDialog — companies · certificate-configurations/history/ui/certificate-history-dialog.tsx](../modules/companies/src/web/certificate-configurations/history/ui/certificate-history-dialog.tsx) | ol |
| E02 | [FolderMappingHistoryDialog — companies · folder-mappings/history/ui/folder-mapping-history-dialog.tsx](../modules/companies/src/web/folder-mappings/history/ui/folder-mapping-history-dialog.tsx) | TimeLine |
| E02 | [MailConfigurationHistoryDialog — companies · mail-configurations/history/ui/mail-configuration-history-dialog.tsx](../modules/companies/src/web/mail-configurations/history/ui/mail-configuration-history-dialog.tsx) | ol |
| E02 | [PrinterHistoryDialog — companies · printer-configurations/history/ui/printer-history-dialog.tsx](../modules/companies/src/web/printer-configurations/history/ui/printer-history-dialog.tsx) | ol |
| E02 | [RemoteConnectionHistoryDialog — companies · remote-connections/history/ui/remote-connection-history-dialog.tsx](../modules/companies/src/web/remote-connections/history/ui/remote-connection-history-dialog.tsx) | ol |
| E02 | [SoftwareHistoryDialog — companies · software-installations/history/ui/software-history-dialog.tsx](../modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx) | ol |
| E02 | [TeleworkAuthorizationHistoryDialog — companies · telework-authorizations/history/ui/telework-authorization-history-dialog.tsx](../modules/companies/src/web/telework-authorizations/history/ui/telework-authorization-history-dialog.tsx) | ol |
| E02 | [WorkProfileHistoryDialog — companies · work-profiles/history/ui/work-profile-history-dialog.tsx](../modules/companies/src/web/work-profiles/history/ui/work-profile-history-dialog.tsx) | ol |
| E02 | [ResourceDetail — resources · resources/detail/ui/resource-detail.tsx](../modules/resources/src/web/resources/detail/ui/resource-detail.tsx) | ol |
| E03 | [WorkstationAssetFields — assets · workstations/shared/ui/workstation-asset-fields.tsx](../modules/assets/src/web/workstations/shared/ui/workstation-asset-fields.tsx) | ul |
| E03 | [WorkstationSummary — assets · workstations/shared/ui/workstation-summary.tsx](../modules/assets/src/web/workstations/shared/ui/workstation-summary.tsx) | ul |
| E03 | [WorkProfileComputerSummary — companies · work-profiles/shared/ui/work-profile-computer-summary.tsx](../modules/companies/src/web/work-profiles/shared/ui/work-profile-computer-summary.tsx) | ul |
| E03 | [WorkProfileSummary — companies · work-profiles/shared/ui/work-profile-summary.tsx](../modules/companies/src/web/work-profiles/shared/ui/work-profile-summary.tsx) | ul |
| E05 | [ProcessDetail — companies · processes/detail/ui/process-detail.tsx](../modules/companies/src/web/processes/detail/ui/process-detail.tsx) | ol |
| E05 | [ProcessStartForm — companies · processes/start/ui/process-start-form.tsx](../modules/companies/src/web/processes/start/ui/process-start-form.tsx) | ol |
| E06 | [DashboardActivityPanel — companies · dashboard/view/ui/dashboard-activity-panel.tsx](../modules/companies/src/web/dashboard/view/ui/dashboard-activity-panel.tsx) | ol |
| E06 | [DashboardAttentionPanel — companies · dashboard/view/ui/dashboard-attention-panel.tsx](../modules/companies/src/web/dashboard/view/ui/dashboard-attention-panel.tsx) | ul |
Los demás candidatos `.map` se revisaron como navegación, opciones de formulario, transformación de valores, estructura de pasos o consumidores de las familias anteriores. La clasificación completa de los 160 candidatos queda en `source-classification.json` dentro de los artefactos, junto al escaneo de los 381 archivos. No se interpretó cada `.map` como una pantalla.
Las ocho composiciones directas compartidas son [OptionSelectionField](../packages/rdx-ui/src/components/option-selection-field.tsx), [FormProgress](../packages/rdx-ui/src/components/form-progress.tsx), [OptionPickerDialog](../packages/rdx-ui/src/components/option-picker-dialog.tsx), [TimeLine](../packages/rdx-ui/src/components/time-line.tsx), [EntityTableContent](../packages/rdx-ui/src/components/entity-table-content.tsx), [OptionPickerList](../packages/rdx-ui/src/components/option-picker-list.tsx), [RemoteOptionList](../packages/rdx-ui/src/components/remote-option-list.tsx) y [FieldPickerList](../packages/rdx-ui/src/components/field-picker-list.tsx). `FormProgress` ordena pasos, no registros administrables. El checklist de columnas, rejilla de iconos y editores de secciones están además en los candidatos por `.map`, aunque no rendericen esos tags directamente.
## 4. Clasificación por tipo
| Tipo | Necesidad dominante | Patrón que conviene conservar |
| --- | --- | --- |
| A Principal | Localizar, comparar y gestionar muchos registros. | Cabecera + herramientas + T; filtros del módulo y navegación explícita. |
| B Sublistado | Entender relaciones sin perder la ficha contenedora. | T cuando hay comparación real; S para relaciones breves; R para historial/proveedor remoto. Composición compacta propuesta, no eliminación automática de herramientas por contar pocas filas. |
| C Selección | Elegir con restricciones y confirmar sin modificar todavía el registro. | TL o R, búsqueda con foco, selección primera, pie estable; preservar diferencias entre elección directa y provisional. |
| D Catálogo/configuración | Administrar definiciones, herencia y vigencia. | T con ámbitos/pestañas y restricciones de uso; editor de secciones S. |
| E Especial | Comunicar cronología, jerarquía, posición o resumen. | Timeline, OL, UL, tarjetas o campos agrupados. No forzar T. |
## 5. Matriz de consistencia
Las tres bandas del patrón son conceptuales. En DOM `EntityTable` contiene cuatro franjas: búsqueda/filtros, resultados/configuración, tabla y pie. Eso no contradice el patrón: resultados y controles pertenecen a herramientas; pie/paginación a datos y navegación.
| Pantallas | Banda 1: cabecera | Banda 2: herramientas | Banda 3: datos/navegación | Diferencia y valoración |
| --- | --- | --- | --- | --- |
| Compañías | Icono, título, contexto y alta. | Búsqueda específica + solo activas; rango/columnas/sort. | Nombre/enlace, abrir visible, menú, página. | Filtro previo del controlador además de T: duplicación a revisar, no fallo visual probado. |
| Activos | Icono, título y alta, pestañas de plantilla. | Búsqueda, rango, columnas dinámicas, sort. | Tipo primero, identidad, estado, sede, puesto, fechas; acciones ancladas; panel/ficha. | Patrón más complejo; referencia para personalización. V08 y V12 abiertos. |
| Recursos | Identidad y ayuda de ámbito más extensa. | Pestañas, búsqueda, rango/columnas/sort. | Nombre/plantilla/estado/acciones; selección contextual. | Más texto eleva inicio de datos en móvil; contrato V01.4 válido. |
| Personas/puestos/perfiles | Cabecera común con acción del dominio. | Contactos/archivados u otros criterios propios. | Enlaces entre entidades y primaria según tarea. | Abrir persona frente a editar puesto/perfil es legítimo; no unificar icono por estética. |
| Sedes/departamentos | Cabecera común, sin ficha inventada. | Genérico y archivadas en Sedes. | Editar y ciclo de vida, páginas locales. | Limpieza parcial de Sedes inconsistente con perfiles. |
| Configuraciones A10–A17 | Icono/título/ayuda y alta específica. | Búsqueda por referencias del dominio; cerradas en accesos remotos y procesos, sin ese filtro adicional en las otras configuraciones citadas. | Estado, periodos, referencias, editar/historial y eliminación donde está disponible. | Carga de varias queries antes de tabla; solo vacío recorrido. |
| Catálogos | Cabecera + ámbito/pestañas y acciones de definición. | Texto por nombre y pestaña de tipo; estado en columnas y acciones. | Tabla de definiciones, editar y retirar. | Mayor altura antes de filas. Herencia/retirada requieren contexto visible. |
| Accesos | Cabecera, ámbito y selector de categoría. | Más controles y ayuda según `kind`. | Columnas/acciones específicas; cobertura de solo lectura. | En 320, tabla de cuentas comienza en y=708: incluso el vacío queda bajo el primer viewport. |
| Sublistados T | Cabecera interior de sección. | Mismas franjas de búsqueda/configuración que principal. | Una relación puede ocupar fila de 56 px más pie. | Oportunidad de composición compacta, no defecto por tener pocas filas. |
| Pickers | Título de diálogo, ayuda/restricciones. | Búsqueda enfocada; T añade configuración completa. | Selección primera, cuerpo desplazable, pie fijo. | Buena estabilidad; capacidad vertical insuficiente en selector de campos móvil. |
Los fallos comunes proceden del reparto de estado, montaje condicionado y composición actual; no se ha demostrado que un componente antiguo separado explique por sí solo los problemas. Las tablas simples de dashboard/proveedor son excepciones funcionales, no legado que deba eliminarse.
## 6. Evaluación visual
### Matriz de pantallas y resoluciones ejecutada
En **cada fila** se midieron 1920×1080, 1440×900, 1366×768, 1024×768, 768×1024, 390×844 y 320×640. `Poblado` indica datos disponibles para comparación, no validación de todas las operaciones. Las filas vacías de HTML no se cuentan como registros.
| Pantalla/variante | Datos recorridos | Capturas de referencia |
| --- | --- | --- |
| Compañías | Una compañía sintética real. | `matrix-companies-{1920,320}.png` |
| Activos | 30, con páginas y relaciones sintéticas. | `matrix-assets-{1920,1024,320}.png` |
| Recursos | 25 aplicaciones sintéticas, activas/inactivas. | `matrix-resources-{1920,1024,320}.png` |
| Personas | 25, contactos, acentos y nombres largos. | `matrix-people-{1920,1024,320}.png` |
| Puestos | 25, responsables/activos/sedes coherentes. | `matrix-workstations-{1920,1024,320}.png` |
| Perfiles | 25 recibidos, 18 visibles por actividad. | `matrix-profiles-{1920,320}.png` |
| Sedes | Cuatro activas y una archivada. | `matrix-sites-{1920,320}.png` |
| Departamentos | Cinco. | `matrix-departments-{1920,320}.png` |
| Infraestructura | Vacío, no inferir un activo por la fila placeholder. | `matrix-infrastructure-{1920,320}.png` |
| Teletrabajo / accesos remotos / procesos | Vacíos reales. | `matrix-{telework,remote,processes}-{1920,320}.png` |
| Carpetas / correo / certificados | Vacíos reales. | `matrix-{folders,mail,certificates}-{1920,320}.png` |
| Aplicaciones / impresoras de perfil | Vacíos reales. | `matrix-{applications,printers}-{1920,320}.png` |
| Cuentas de compañía | Vacío real. | `matrix-access-{1920,320}.png` |
| Catálogo activos / catálogo recursos | Definiciones del entorno, pobladas. | `matrix-{asset-catalog,resource-catalog}-{1920,320}.png`; activos también 1024. |
| Marcas / plantillas de proceso | Definiciones disponibles del entorno. | `matrix-{brands,process-templates}-{1920,320}.png` |
| Sistemas globales | Vacío real. | `matrix-global-access-{1920,320}.png` |
Son 23 variantes × siete tamaños = **161 mediciones correctas**, sin overflow horizontal global observado. Las capturas se reservaron principalmente a extremos y puntos intermedios relevantes. Los JSON contienen los siete tamaños; las capturas no son la matriz completa.
### Mediciones y observaciones
| Pantalla | Inicio de tabla 1920 / 320, px | Primera fila 1920 / 320, px | Lectura |
| --- | --- | --- | --- |
| Compañías | 324 / 422 | 56,5 / 56,5 | Cabecera y herramientas estables, pocos datos de muestra. |
| Activos | 380 / 487 | 57 / 125 | En 320 la primera fila empieza tras cabecera de columnas, y=527: no cabe completa en 640. |
| Recursos | 384 / 554 | 61 / 77 | Primera fila y=594; explicación y herramientas consumen casi toda la altura inicial. |
| Personas | 316 / 439 | 77 / 141 | Descripciones y relaciones hacen crecer filas, incluso por contenido de columnas fuera de vista. |
| Puestos | 320 / 447 | 57 / 57 | Más uniforme con la muestra, no garantía para todo dato futuro. |
| Catálogo de activos | 428 / 586 | 61 / 65 | Primera fila y=626: apenas aparece en 320×640. |
**Observación visual:** tipografía, bordes, iconos y jerarquía de cabecera son reconocibles y coherentes con DESIGN. Nombre y acciones destacan; el contenedor limita el scroll horizontal a la tabla. No se propone reducir la fuente ni abandonar Vega para ganar espacio. El color de estado aparece junto a texto; no se midieron todos los pares de contraste, por lo que no se certifica su conformidad.
**Problema de productividad L01-01:** evitar overflow global no garantiza una superficie útil de consulta. La combinación de subtítulo, acción, pestañas, búsqueda, rango y configuración deja muy pocas filas inicialmente visibles. En el selector de campos de 320×640, la tabla comienza en y=497 y su contenedor tiene **0 px de altura**, aunque su scrollHeight es 950 px. El cuerpo exterior mide 332 px y el pie 157 px. Tras desplazar el cuerpo exterior 272 px, el contenedor de filas sigue midiendo cero: no es solo que las filas queden por debajo del primer viewport. El DOM contiene diez filas, pero no dispone de una superficie visible para mostrarlas. Véanse ambas capturas y `picker-capacity-results.json`.
**Sublistados:** una asignación de persona de 56,5 px convive con 130 px de herramientas y 61 px de pie. La proporción justifica una composición embebida explícita; ocultar automáticamente controles al pasar de diez a nueve registros produciría saltos y no es la recomendación.
La tabla admite nombres y texto técnico largos mediante wrap/scroll, pero puede aumentar alturas por columnas no visibles horizontalmente. Conviene priorizar qué dato va en nombre/subtítulo y qué permanece en detalle; truncar indiscriminadamente IP, MAC o rutas perjudicaría soporte. La muestra no contiene interfaces reales múltiples suficientes para validar esa decisión en infraestructura.
## 7. Búsqueda y filtros
### Resultados funcionales
| Listado | Entradas probadas | Resultado |
| --- | --- | --- |
| Activos | `portatil` / `portátil` / `PORTÁTIL` | 0 / 30 / 30: V07 confirmado. |
| Activos | ` portátil ` / `L01-EQUIPO` | 30 / 29: trim y coincidencia parcial funcionan en su filtro. |
| Activos | `192.168.5.1` / `#` | Sin resultados; no prueba fallo de búsqueda por IP porque no forma parte de los campos buscables del contrato actual. |
| Recursos | `aplicacion` / `aplicación` / `APLICACIÓN` | 0 / 25 / 25. |
| Recursos | ` aplicación ` | 0: falta trim en el filtro propio, a diferencia de activos. |
| Personas | `alvaro` / `álvaro`; `nora` / `ñora`; `Pena` / `Peña` | Se encuentran registros distintos; no hay equivalencia de tildes y ñ permanece diferenciada. |
| Personas | Nombre con espacios externos / búsqueda por correo | Trim sí; correo no por contrato de nombre, no por defecto demostrado. |
| Catálogo | `portatil` / `portátil` / texto con espacios externos | 0 / 1 / 0 en la muestra. |
| Departamentos | Texto parcial con espacios | Filtro genérico T recorta espacios. |
| Dominio remoto | `audit` | Consulta remota y vacío real; no se evaluaron resultados poblados ni paginación. |
La ordenación de nombres sí usa Collator; eso **no normaliza** los `includes` de búsqueda. V07 afecta a filtros propios de inventarios/personas/catálogos y al comportamiento genérico sin equivalencia de acentos. No se debe arreglar cambiando únicamente la ordenación.
### Contrato propuesto
Normalizar ambos operandos: recortar extremos, ignorar mayúsculas y equivalencias canónicas/acentos de vocales; conservar **ñ distinta de n**. Probar `Álvaro`, texto Unicode compuesto/descompuesto, `Peña/Pena`, espacios, números, guiones, puntos y DNS. No eliminar puntuación de identificadores técnicos ni aplicar una transliteración general que convierta `ñ` en `n`. El helper común implementaría comparación; **el módulo decide los campos y sus etiquetas de ayuda**.
Mantener búsqueda inmediata local, con estado de resultados anunciado sin multiplicar regiones live. En búsqueda remota, conservar contrato del proveedor, cancelación/descartado de respuestas antiguas y reinicio de cursor al cambiar criterio; no filtrar solo la página visible para aparentar búsqueda global. Esta parte requiere fixture remota poblada antes de aprobar comportamiento final.
**L01-03, Sedes:** activar archivadas, escribir `L01` y pulsar «Limpiar filtros» conserva el checkbox y los cinco registros. La búsqueda se borra. El componente no entrega el filtro archivadas a `filtersApplied`, `filterKey` y `onClearFilters` como hacen otros consumidores. Acordar si es filtro o preferencia de vista; si es filtro —recomendado por coherencia con perfiles— debe indicarse y limpiarse con el resto. No asumir que todas las preferencias deben resetearse.
## 8. Ordenación y contexto
### V05 reproducido por dimensiones independientes
Pasos: activos con 30 registros → plantilla Portátil → búsqueda `portátil` → nombre descendente → página 3 → abrir **el activo original** `AUDIT-PORT-01` por su enlace → volver con historial del navegador.
| Dimensión | Antes | Después | Veredicto |
| --- | --- | --- | --- |
| Búsqueda | `q=portátil` | Conservada en URL y control. | Correcto en este flujo. |
| Plantilla | ID de Portátil en URL. | Conservado. | Correcto. |
| Orden | Nombre descendente. | Nombre ascendente. | V05 confirmado funcionalmente. |
| Página | 3, registros 21–30. | 1, registros 1–10. | Pérdida confirmada. |
| Scroll vertical, 1366×768 | Workspace y=100. | y=474. | No se restaura la posición esperada. |
| Scroll horizontal, 1366×768 | 0, tabla cabía. | 0. | No ejercita restauración horizontal. |
| Scroll horizontal, 390×844 | 40 px, tabla desplazada. | 0 px. | Pérdida confirmada en prueba adicional. |
| Foco al volver | Navegación desde enlace. | `body` en este recorrido. | Definir retorno al elemento/alternativa sin causar scroll inesperado. |
En Personas se conservaron búsqueda y «Solo contactos» mientras permanecía el componente padre, pero la ordenación volvió a ascendente porque `EntityTable` se desmontó. No se afirma que se pierda la búsqueda en ese flujo. Sort/página locales en el hook compartido explican un riesgo transversal al desmontar; no se recorrieron todos los retornos de todas las entidades.
La ordenación inicial de nombres ignora tildes y usa comparación numérica: Álvaro 2 precedió a Alvaro 10; `ñ` conserva la posición del idioma español. Las cabeceras declaran `aria-sort` y activación por control; los empates conservan el orden de entrada. Valores de características numéricas/fechas no se convierten todos a cadenas para ordenar: se usan los valores entregados por sus columnas. No se ha demostrado un fallo numérico universal.
### Contrato propuesto de contexto
| Estado | Destino recomendado | Regla |
| --- | --- | --- |
| Búsqueda, filtros, pestaña/plantilla, sort y página | URL validada por consumidor cuando representan una consulta navegable. | IDs de columna conocidos, direcciones cerradas, página acotada. Al cambiar criterio reiniciar página explícitamente; al volver recuperar contexto, sin duplicar caché. |
| Tamaño de página y visibilidad | Preferencias locales existentes, con alcance y fallback definidos. | Mantener `entity-page-size` salvo decisión explícita de cambiar alcance; visibilidad por clave del listado. URL puede sobreescribir tamaño solo si se aprueba ese contrato. |
| Scroll X/Y, elemento de retorno y selección contextual | Entrada de navegación/contexto de retorno del cliente. | Restaurar tras datos/layout, limitar a rango válido si faltan registros; no mantener nodos DOM ni copiar objetos remotos en almacenamiento. |
| Menús, hover y selección provisional de un picker | Estado efímero del componente. | Cancelar no muta la entidad; no serializar indiscriminadamente. |
| Inventarios, entidades y permisos | TanStack Query. | Claves/invalidation actuales, sin una segunda caché de DTOs en URL/localStorage. |
Activos y recursos conservarán su navegación diferente. Una selección de recurso puede estar en su ruta contextual; el contrato de retorno no exige inventar una ficha standalone equivalente a la de activos.
## 9. Columnas y personalización
V08 se reprodujo con **75 checkbox** en el inventario, con el catálogo del propio entorno: 20 columnas base/adicionales/acciones y 55 características dinámicas. La lista es plana, sin buscador de texto, grupos, restauración ni ordenación de columnas. No se contabilizaron como buscadores los inputs ocultos de checkbox. El diálogo midió 576×868 en 1440×900 y 288×608 en 320×640, con scroll interno y cierre accesible.
Inicialmente hay ocho columnas visibles: tipo, nombre, plantilla, estado, sede, puesto, fecha de alta y acciones. Se comprobó que ocultar Sede, cerrar y recargar conserva la preferencia `{"site":false}` bajo `asset-inventory-columns`. Nombre/tipo/puesto/acciones están protegidos en ese consumidor; **no se pierde la acción de puesto ocultando su columna**. La preferencia guarda visibilidad, no valores de activos. IDs UUID de características evitan depender de una traducción; campos retirados presentes en snapshots también pueden existir como columnas. Etiquetas duplicadas se distinguen con ordinal, no duplicando la identidad del campo.
En inglés se verificaron etiquetas comunes traducidas y el inventario a 320 px. Los nombres del catálogo siguen siendo datos de su definición; no son necesariamente traducciones faltantes del frontend. El selector de puesto revela además rango «Mostrando 1–10 de 25 seleccionar puesto», construido con un título de acción como sustantivo: mejora de microcopy L01-04, no fallo de paginación.
**Propuesta de API, no implementación:** extender opcionalmente la definición genérica de columna con `groupId`, `keywords` y `align: 'start' | 'end'`; aportar grupos `{ id, label }` y textos traducidos desde el consumidor. Mantener `id`, `hideable`, visibilidad y render actuales. El módulo construye «General», «Estado», «Auditoría», «Infraestructura» y grupos de características, resuelve campos compartidos entre plantillas y aliases de búsqueda. rdx-ui no recibe DTO, tipos de plantilla ni endpoints.
El diálogo podría buscar por etiqueta/keywords, agrupar sin duplicar checkbox para un mismo UUID, mantener visibles/protegidas identidad y acciones, mostrar recuento seleccionado y restaurar la configuración inicial del consumidor. Restaurar no debe borrar datos de otras tablas. Conservar aplicación inmediata más «Cerrar», o aprobar explícitamente una semántica provisional con aplicar/cancelar; no mezclar ambas silenciosamente. Reordenar columnas y selector de densidad quedan pendientes de necesidad real, no se incluyen automáticamente en V08.
## 10. Acciones y selección
### Lo verificado
`EntityRowActions` conserva icono primario visible, nombre accesible y tooltip; el menú de tres puntos contiene acciones secundarias. Las acciones destructivas permanecen explícitas y condicionadas por el dominio. Se abrieron formularios y se cancelaron: no se ejercitaron guardados, transiciones destructivas ni autorizaciones de escritura.
En activos, el enlace del nombre abrió la ficha; el clic no interactivo permite contexto. En recursos a 1920 se seleccionó una fila, cambió la ruta al recurso y apareció el detalle integrado. Abrir «Ficha e historial» convirtió ese contexto en diálogo explícito. Cerrar devolvió foco a un control, no a `body`. Al pasar a 1024 no se abrió automáticamente otro diálogo por redimensionar; una apertura explícita y Escape regresaron a la ruta contextual correcta. Es el contrato específico de [V01.4](recursos-responsive-v01-4.md), que se conserva.
Los pickers de activos, campos y opciones enfocaron la búsqueda al abrir. Cambiar a cero resultados conservó las dimensiones de los diálogos. Escape cerró el picker hijo y devolvió foco a un botón del formulario padre. La selección por radio/checkbox tiene nombre accesible y la selección primera no puede ocultarse. Esto no acredita todos los recorridos de teclado: se comprobaron búsqueda, apertura/cierre, controles de columnas y retorno, no lectores de pantalla ni toda combinación de teclas/selección remota.
### L01-02 — Identidad ocultable en un selector
Reproducción: nuevo perfil → seleccionar puesto → Columnas → ocultar «Puesto de trabajo» y «Detalles» → cerrar configuración. Quedan diez radios y únicamente la cabecera «Selección», sin identificación visual de las opciones. Los radios conservan su nombre accesible, pero una persona que mira la pantalla no puede distinguir qué elige. Captura `option-identity-hidden.png`; medición `extra-final-results.json`.
La columna `name` de `OptionPickerList` no declara `hideable:false`; `FieldPickerList` sí protege la suya. La solución pertenece a la definición de ese consumidor compartido, no a prohibir todas las columnas opcionales de `EntityTable`. En la rama de compañías de `DashboardDetailsDialog` también falta esa protección: riesgo detectado en código, **no reproducción visual adicional**. Revisar consumidores equivalentes con criterio de identidad mínima.
### L01-04 — Texto visible y nombre accesible
En el paso de activos del puesto, el botón muestra «Seleccionar activos» pero su nombre accesible es «Añadir activos». La instrumentación necesitó localizar el segundo. Procede de `AssetSelect` con presentación de acción y props diferentes para texto/aria-label. Conviene que el nombre accesible incluya el texto visible para control por voz y coherencia. No es lo mismo que «Añadir campo: Sin sección», cuyo nombre contextual sí incluye el texto visible. Revisar también el rango del picker descrito en la sección 9.
La fila clicable es un atajo complementario: no sustituir enlaces/botones por `div` interactivos ni añadir tabIndex a todas las celdas. `EntityTableContent` evita activar la fila al pulsar elementos interactivos. Conservar enlaces a persona, activo y puesto donde existan fichas; conservar nombre informativo o editor/modal donde ese sea el contrato real.
## 11. Estados de carga/vacío/error
| Escenario ejecutado | Resultado observado | Recomendación |
| --- | --- | --- |
| V12, GET inicial retenido por compuerta | Cero tablas, búsquedas y controles Columnas. Permanecen título y alta deshabilitada con «Cargando catálogo…». | Reservar herramientas y estructura con componentes de carga existentes; diferenciar dependencias de catálogo e inventario. |
| Liberar respuesta inicial | Aparecen tabla, búsqueda y configuración. | Evitar salto de identidad/altura de la composición; no mostrar filas vacías como datos. |
| Refetch retenido con datos previos | Tabla/búsqueda/columnas se mantienen; `aria-busy=false`. | Mostrar actualización y anunciarla de forma sobria; conservar controles que siguen siendo válidos. |
| 503 inicial **simulado** | Mensaje recuperable y «Reintentar», sin tabla. Al retirar simulación, reintentar recupera datos. | Estado de error real del contenedor, sin skeleton eterno ni errores técnicos expuestos. |
| 503 de refetch **simulado** | Datos previos + mensaje y reintento; falta identificación explícita de antigüedad/actualización. | Indicar que se muestran datos anteriores y que falló actualizar; no aparentar frescura. |
| Recursos sin registros, GET **simulado** vacío | Herramientas, rango 0 y «No hay resultados»; existe «Nuevo recurso» en cabecera. | Explicar primera alta si aporta contexto; no es un callejón sin salida. |
| Recursos con filtro sin coincidencias | Mensaje de criterios y «Limpiar filtros»; recuperar funciona. | Mantener distinción vacío/filtrado y búsqueda. |
| Activo seleccionado ausente, transformación GET **simulada** | «No se ha encontrado el registro en este ámbito»; no se borró entidad. | Fallback de navegación/retorno y foco según contexto; no presentar error de permiso como ausencia sin contrato. |
| Usuarios con sesión ordinaria | GET real 403, mensaje «No puedes realizar esta acción…» y enlace Inicio. | Restricción correcta, no crear usuario administrativo para completar una captura. |
V12 se amplía con el contrato de actualización, **no se duplica como nueva incidencia ni se declara resuelto**. `EntityTable` ya acepta carga, pero un padre que lo desmonta no permite reservar su estructura. El trabajo requiere composición en el módulo además de una capacidad común.
Conservar el patrón actual de ausencia en celdas: `—`; cero y estados válidos no son ausencia. Los mensajes de listado vacío y de error sí necesitan texto. Los skeletons deben reservar estructura plausible sin inventar contenido ni copiar datos de otra compañía. En errores posteriores, no limpiar filtros ni cerrar formularios para forzar una recuperación aparente.
## 12. Rendimiento y escalabilidad
### Evidencia y límites
`useCompanyDetailController` consulta inventarios completos de activos, recursos y perfiles para sus contadores laterales, incluso al consultar Sedes. Las claves compartidas permiten deduplicar con el inventario cuando este se abre; no son necesariamente tres descargas nuevas por cada render. Personas/sedes/departamentos se cuentan desde el agregado de compañía. Las consultas de listado que utilizan `useListQuery` refrescan al recuperar foco/conexión y según su intervalo; no confundir ese comportamiento con una petición por pulsación de búsqueda local.
En el servidor desechable **sin sustitución del navegador**, GET devolvió 200 y tamaños JSON decodificados de 6.261 bytes para activos, 32 para recursos y 15 para perfiles. Estos números corresponden a un activo y colecciones vacías. No muestran un problema de carga actual ni representan una compañía de producción.
Se ejecutó además una muestra de volumen **solo en navegador**, manteniendo DTO válido y sin escrituras:
| Activos recibidos | JSON sintético decodificado | Mediana de cinco muestras, ms | Máximo, ms |
| --- | --- | --- | --- |
| 30 | 186.469 bytes | 99,8 | 127,0 |
| 300 | 1.864.249 bytes | 128,0 | 172,5 |
| 1.000 | 6.214.049 bytes | 122,8 | 127,6 |
La medida se ejecutó a 1440×900 y va de `fill` de Playwright a dos frames, **incluye automatización** y no es tiempo de React, latencia de red ni input-to-paint instrumentado dentro del evento. Cinco entradas produjeron cantidades de coincidencias diferentes; las medianas no crecen de forma monotónica. No se controlaron CPU/red como en un laboratorio dedicado ni se utilizó un móvil físico. La tabla mantuvo como máximo diez filas de datos en DOM por página; el filtrado/ordenación siguen procesando el conjunto cliente. Estos resultados **no demuestran lentitud** ni justifican por sí solos virtualización o paginación remota.
### Riesgos y oportunidades, separados de defectos demostrados
- Inventarios con historial completo pueden crecer mucho más que el número de entidades. Medir bytes transferidos y decodificados, parseo, retención en caché y frecuencia de refresco antes de proponer un endpoint de contadores.
- Las columnas dinámicas recorren catálogo/snapshots para construir la unión de campos; búsquedas y algunos lookups de relaciones recorren colecciones. Revisar costo con profiler antes de introducir índices, memoización o nueva arquitectura.
- Compañías filtra en el controlador y entrega a T: posible redundancia, no cuello de botella medido.
- Cambiar visibilidad no reduce el tamaño descargado. Paginación local tampoco. El número de filas DOM no mide toda la memoria del inventario.
- El dashboard de totales existente no cubre automáticamente los tres contadores necesarios; reutilizarlo exige comprobar su contrato, no asumir equivalencia.
### Campaña de medición propuesta
Probar 100/1.000/10.000 entidades, 1/20/100 revisiones por entidad, ocho/30/75 columnas disponibles, páginas de diez y cien, búsquedas con pocas/muchas/ninguna coincidencia y tablas con/sin panel. Añadir nombres técnicos largos y relaciones numerosas. Instrumentar P50/P95 de evento a pintura, tareas largas, scripting/layout, tamaño comprimido/decodificado, heap retenido, parseo y número de peticiones por navegación/refetch. Hardware, red y caches deben declararse y repetirse.
Solo si esas medidas incumplen un presupuesto acordado de interacción o transferencia se decide paginación remota, índices de búsqueda, contadores agregados o virtualización para el consumidor afectado. No se fija un umbral arbitrario de filas ni se incluye backend en L02–L04 sin esa dependencia demostrada.
## 13. Hallazgos priorizados
Alta: impide o compromete una tarea básica en el escenario reproducido. Media: añade pasos, desorientación o pérdida de contexto. Baja: claridad/composición mejorables. Riesgo: requiere medición o datos. Las prioridades son de producto y no una certificación de accesibilidad.
| ID | Gravedad / clasificación | Escenario, alcance y componente | Evidencia y recomendación |
| --- | --- | --- | --- |
| L01-01 | Alta en selector de campos móvil; media en listados / V+F+DOM | C02, 320×640: área de filas colapsada; A02/A03/D01 y A18: datos demasiado abajo. Composición `FormBody` + TL/T, herramientas y ayudas del consumidor. | `field-picker-capacity-320.png`, `picker-capacity-results.json`, matriz móvil. Reservar cuerpo útil, reducir redundancia/componer herramientas, ayuda contextual compacta y pie adaptado. Ver L02. |
| L01-02 | Alta / V+F, extensión C | C03: ocultar nombre y detalles deja radios sin identidad visible; `OptionPickerList`. Riesgo equivalente en dashboard, solo C. | `option-identity-hidden.png`, `extra-final-results.json`. Proteger identidad mínima en consumidores; probar combinaciones de visibilidad. L02. |
| V05 | Media / V+F | Activos, 1366×768 y 390×844; Personas 1440×900. Hook con sort/página local, navegación del módulo. | `v05-before.png`, `v05-after.png`, `v05-mobile-return.png`, `flows-context-results.json`, `extras-results.json`. Contrato URL/retorno propuesto en sección 8. L03. |
| V07 | Media / F+C | Activos, recursos, personas y catálogos; tildes y trim divergentes. Filtros del módulo y búsqueda T. | `v07-unaccented.png`, `v07-accented.png`, `flows-search-results.json`. Normalización común con campos explícitos y ñ distinta. L03. |
| V08 | Media / V+F | A02, selector plano de 75 columnas, 1440×900 y 320×640. `EntityTableColumnsDialog` y metadatos del módulo. | `v08-columns-{1440,320}.png`, `flows-columns-results.json`. Búsqueda, grupos, identidad y restauración; preferencias conservadas. L02. |
| V12 | Media / V+F+DOM | A02, carga inicial y refetch controlados, 1440×900. Montaje en página y estado del listado. | `v12-initial-held.png`, `v12-background-held.png`, `states-results.json`. Estructura reservada, estado de actualización/antigüedad y recuperación. L02. |
| L01-03 | Media / F+C | A07: archivadas + búsqueda + limpiar conserva filtro archivadas. | `site-clear-keeps-archived.png`, `flows-search-results.json`. Acordar filtro frente a preferencia y limpiar de forma coherente. L03. |
| L01-04 | Baja / snapshot+C | C01: «Seleccionar activos» visible frente a «Añadir activos» accesible; C03: rango usa infinitivo. | `workstation-step-2.txt`, `option-picker-1440.txt`; `AssetSelect`/textos aportados al picker. Separar texto de entidad y acción; alinear nombre accesible. L02. |
| O01 | Baja / V+DOM, oportunidad | Sublistados con una relación y herramientas completas. | `person-assignments-1440.png`, `workstation-related-1440.png`. Composición embebida explícita, sin cambios automáticos por número de filas. L04. |
| O02 | Baja / C, propuesta | Alineación de números: contrato genérico carece de metadato; tabla resumen simple usa alineación común. | `EntityTableContent`, `DashboardTable`. Añadir alineación opcional donde la comparación de cantidades la requiera; no convertir IDs/IP/fechas en números. No fallo visual global probado. L02. |
| R01 | Riesgo / C+medición limitada | Descarga completa para contadores, columnas y relaciones dinámicas. | `extras-results.json`, `benchmark-results.json`. Medir campaña de sección 12; ninguna modificación de backend aprobada por esta auditoría. |
| D01-doc | Baja / C documental | Descripción antigua de clic en nombre de activo y umbral viewport fijo en documentos frente a contratos V01.4 más recientes. | Componentes/rutas documentados y código actual. En L04 conciliar documentación, sin modificar reglas en L01. |
No se duplican V05/V07/V08/V12 con nuevos nombres. **V01, V02, V03, V06 y V09 conservan el estado de resueltos en sus escenarios previamente verificados**; L01 no reabre sus decisiones de layout, contraste o navegación. Observar capacidad vertical insuficiente en herramientas de un listado o picker nuevo no equivale a revertir el shell aprobado. No se ha repetido toda su batería histórica y tampoco se amplía su alcance de validación.
## 14. Propuesta de patrón objetivo
### Mantener
React 19, TanStack Query/Table, Tailwind, Base UI/Vega y la composición `@repo/rdx-ui` sobre primitivas vendor. Cabecera con icono, título, contexto y acción; nombre ordenado según idioma; `—` para ausencia; selección primera; acciones principales visibles y accesibles; menú secundario; tabla con overflow propio. Formularios y diálogos independientes, con ayuda antes del pie y altura estable. No mover lógica de dominio ni consultas a componentes presentacionales.
### Evolucionar, pendiente de aprobación
| Área | Contrato objetivo |
| --- | --- |
| Estructura | Tres bandas conceptuales, composición amplia/embebida elegida por contexto y capacidad real. Evitar duplicar rango/controles sin necesidad; no esconder información solo para cumplir un breakpoint. |
| Herramientas | Búsqueda visible como acción frecuente; filtros del dominio y estado activo claro; configuración secundaria accesible. En móvil agrupar controles secundarios conservando búsqueda y señal de filtros. |
| Datos | Identidad mínima protegida; prioridad definida por módulo; columnas secundarias disponibles y valores técnicos íntegros consultables. Scroll horizontal interno, acciones ancladas, fechas/números con valor comparable y formato separado. |
| Columnas | API opcional genérica de grupos, keywords y alineación. Selector buscable y agrupado con restauración de defaults. IDs estables y preferencias validadas. Sin reordenación/densidad nueva por defecto. |
| Búsqueda | Helper de equivalencia textual común; campos y pistas aportados por módulo. Contrato remoto separado. |
| Orden/página | Estado controlable desde consumidor donde deba sobrevivir navegación; validación de parámetros y ajuste ante filtros o desaparición de registros. |
| Selección | Checkbox/radio semánticos, restricciones visibles y selección provisional cuando proceda. No introducir selección masiva en todos los inventarios sin operación real. |
| Carga | Reserva de composición inicial con skeleton/estado existentes; refetch conserva datos e indica actualización; error posterior identifica datos previos y ofrece reintento. |
| Vacío/error | Distinguir sin registros, sin coincidencias, error y falta de permiso. Acción contextual útil ya sea en cabecera o mensaje; no repetir CTA por rutina. |
| Foco/accesibilidad | Nombre visible incluido en nombre accesible; headers `scope`, `aria-sort`, controles operables por teclado; retorno explícito tras ficha/modal; estados anunciados sin duplicación. Auditar contraste de pares concretos antes de afirmar cumplimiento. |
| Responsive | Decidir por contenedor y tarea, respetando presupuestos V01.3/V01.4. Medir área real de filas en pickers; no basta comprobar dimensiones del modal. No achicar fuente ni introducir tarjetas universales. |
| Personalización | Mantener visibilidad y tamaño existentes; restaurar solo ámbito correspondiente. Orden/densidad adicionales requieren casos de uso y prueba de capacidad. |
### Reparto arquitectónico
**rdx-ui:** estructura, semántica tabular, controles, visibilidad, grupos genéricos, estados presentacionales, mecanismos controlados de sort/página, selección y foco de primitives propias. APIs explícitas/composición; evitar proliferación de flags como `isCompany`, `isAsset`, `compactMobileInventory`.
**Módulos:** DTO/query keys, carga/refetch/error real, columnas/valores/etiquetas, grupos de características, permisos, reglas de vigencia, campos buscables, filtros, construcción/validación de URL, navegación y acciones. Un helper común de cliente puede conservar contexto sin conocer endpoints, siempre que haya consumidores reales.
**Proveedor remoto:** orden, cursor/página, total si existe, elegibilidad y búsqueda global. No simular totales ni filtrar una página aislada desde T. **Vendor:** usar su API, no editar su implementación.
Excepciones que deben documentarse, no corregirse: nombres informativos sin ficha; recursos con selección contextual; timeline cronológico; tareas/secciones por posición; dashboard resumido; pickers de elección directa frente a provisional; acceso global heredado/protegido.
## 15. Alternativas y wireframes
Son esquemas de propuesta, **no capturas de una implementación**. No fijan tokens, medidas de fuente ni nuevos breakpoints.
### 1. Listado sencillo
```text
[Personas] Personas [+ Nueva persona]
Contexto breve
┌ Buscar persona… ───────┐ [Solo contactos] [Filtros activos · Limpiar]
25 personas [Columnas] [Orden]
┌ Nombre ↑ ───────── Estado ─ Teléfono ─ Puestos ───────── Acciones ┐
│ Álvaro 2 Activa — Diseño ↗ ◉ ⋮ │
│ … │
└ 1–10 de 25 [10 por página] [Anterior] 1 2 3 ─┘
```
### 2. Inventario con numerosas columnas
```text
[Activos] Activos [+ Nuevo activo]
[Todo] [Portátil] [PC sobremesa] [Impresora] …
┌ Buscar por nombre, plantilla, estado o sede… ┐ [Filtros · Limpiar]
30 activos [Columnas · 8 visibles] [Orden]
┌ Tipo ─ Nombre ↑ ─ Plantilla ─ Estado ─ Sede ─ Puesto ─ Fecha ─ ⚙ ┐
│ ▱ EQUIPO-02 ↗ Portátil En uso Central Diseño ↗ … ✎ ⋮│
└ ← desplazamiento interno; acciones siguen disponibles → ───────┘
1–10 de 30 [Página / tamaño]
```
### 3. Inventario con contexto
```text
Contenedor con capacidad suficiente
┌ Herramientas de listado ─────────┐ ┌ Contexto de selección ──────┐
│ Tabla, enlaces y acciones │ │ Nombre / estado │
│ Fila seleccionada │ │ Secciones y relaciones │
│ Scroll propio │ │ [Ficha e historial] [Cerrar] │
└ Página ─────────────────────────┘ └─────────────────────────────┘
Capacidad insuficiente: mantener tabla; abrir diálogo por acción explícita.
Activo: enlace de nombre → ficha completa. Recurso: contrato contextual V01.4.
```
### 4. Listado móvil
```text
Contexto de compañía existente
[Activos] [+ Nuevo]
[Todo] [Portátil] …
[ Buscar… ]
[Filtros · 1] [Configurar] 30 activos
┌ Nombre ↑ ─────────────── Acciones ┐
│ EQUIPO-02 ↗ ✎ ⋮ │
│ … │
└ ← columnas adicionales → ────────┘
[1–10 de 30] [Página]
```
Configurar agruparía acciones secundarias solo si las pruebas justifican el paso adicional. No ocultar búsqueda, filtros activos ni acción principal. En picker reservar filas antes de añadir ayuda extensa; ayuda plegable existente y acciones secundarias deben componerse sin colapsar la tabla.
### 5. Selector de columnas
```text
Columnas [×]
[ Buscar columna… ]
8 visibles [Restaurar inicial]
GENERAL
☑ Nombre (obligatoria) ☑ Tipo (obligatoria) ☑ Plantilla
ESTADO Y UBICACIÓN
☑ Estado ☑ Sede ☑ Puesto
AUDITORÍA
☐ Creado el ☐ Modificado por
INFRAESTRUCTURA
☐ Interfaces ☐ Direcciones IP
CARACTERÍSTICAS · campos del catálogo
☐ Marca ☐ Modelo ☐ Número de serie
scroll interno
[Cerrar]
```
### Decisiones comparadas
| Problema / alternativas | Comparación | Recomendación |
| --- | --- | --- |
| Selector plano / búsqueda / búsqueda + grupos | Plano exige recorrer 75; búsqueda reduce interacción pero requiere saber nombre; grupos aportan exploración y búsqueda específica con coste técnico moderado. Grupos no deben multiplicar checkboxes del mismo campo. | Búsqueda + grupos genéricos, con metadatos del módulo. Mayor reutilización sin acoplar plantillas a rdx-ui. |
| Todo en URL / todo localStorage / reparto semántico | URL comparte consulta pero no sirve para nodos/foco ni DTOs; storage indiscriminado crea contexto inesperado entre pantallas. | Reparto de sección 8; más explícito y verificable. |
| Reducir fuente / composición de herramientas / tarjetas universales | Fuente menor empeora legibilidad; tarjetas pierden comparación y no resuelven 75 campos; composición conserva identidad y gana altura, con un posible paso para opciones secundarias. | Composición según contenedor, manteniendo DESIGN y tabla. |
| Quitar controles cuando hay pocas filas / composición embebida explícita | Conteo variable provoca saltos, visibilidad inconsistente y dificultad para encontrar configuración. Una variante por contexto requiere API clara pero estable. | Variante/composición embebida semántica, no umbral por cantidad. |
| Ocultar acciones al hover / iconos visibles + menú | Hover ahorra ruido visual pero perjudica descubrimiento, touch y teclado. El patrón actual ya resuelve secundaria/principal. | Mantener acción principal visible con nombre/tooltip y menú secundario. |
| Tabla local universal / remota universal / contrato por fuente | Universalizar rompe cursor/proveedor o añade backend sin evidencia. Conservar ambos requiere pruebas específicas, pero expresa la realidad. | T/TL/R separados; optimizar solo tras medición. |
| Altura estable de modal / altura útil estable de filas | Estabilidad exterior sola puede ocultar un contenedor de tabla de cero px. | Comprobar ambas, con una capacidad mínima acordada por tarea y pruebas de teclado/scroll. |
## 16. Plan L02–L04
### L02 — Componentes comunes y composición
**Objetivo:** resolver V08 y V12, L01-01/L01-02/L01-04 y preparar alineación opcional O02. La corrección inicial de V12 se entrega completa con el consumidor activos; no dejar una prop sin integración. La capacidad de picker móvil se valida también en el consumidor real, no solo en un story aislado.
**Componentes:** `EntityTableColumnsDialog`, `EntityTableConfiguration`, `EntityTableContent`, definición de columnas, `OptionPickerList`, composición `FormDialog`/`FormBody`/TL, `AssetSelect` y textos del consumidor; integración acotada en inventario/catálogos/pickers afectados. Reutilizar primitivas de skeleton y controles existentes; sin tocar vendor.
**Dependencias:** aprobar grupos y semántica de restauración, capacidad visible de filas/ayuda en picker y agrupación móvil de herramientas. Preservar IDs/preferencias y contratos V01. No implementar todavía sort en URL ni normalización: pertenecen a L03.
**Riesgos:** API demasiado genérica, cambio accidental de persistencia, pérdida de selección al filtrar, scroll anidado, foco dentro de modales anidados y skeleton que parezca dato. Introducir props opcionales con consumidor real y responsabilidad clara; no una matriz de flags por entidad.
**Pruebas:** selector de 3 y 75 columnas, búsqueda/grupos/restore/obligatorias, reload con preferencia anterior, es/en; carga inicial/refetch/error con compuerta; pickers 320×640/390×844/1440×900 con filas realmente visibles, radio/checkbox, Escape/retorno de foco. Regresión responsive de activos/recursos y acciones ancladas.
**Salida:** V08 y V12 reproducidos antes y resueltos después con evidencia; identidad mínima imposible de ocultar, ningún picker con área de filas colapsada; estados y nombres accesibles coherentes. Medir densidad antes/después, sin reducir tokens tipográficos. **Reversión:** commits separados de capacidades e integración, mantener esquema de IDs/almacenamiento, revertir consumidores y props juntos sin migrar datos de negocio.
### L03 — Productividad y conservación de contexto
**Objetivo:** V05 y V07, y decisión/corrección L01-03. No volver a asignar aquí V08/V12 entregados en L02; solo regresión.
**Componentes:** hook/control de tabla, helper de comparación textual de cliente con consumidores existentes, controladores de activos/recursos/personas/catálogos y otras búsquedas equivalentes; navegación/retorno y Sedes. El módulo conserva sus campos buscables y permisos.
**Dependencias:** contrato de URL, estado de retorno y comportamiento de `ñ`; catálogo de filtros/columnas válidos por consumidor. Auditar si cambiar URL por pulsación debe usar reemplazo de historial para no generar un paso Atrás por letra.
**Riesgos:** borrar parámetros de otras operaciones, conservar página inválida, restaurar scroll antes de layout, romper recurso contextual, equivalencia excesiva en identificadores técnicos. Sin duplicación de TanStack Query ni persistencia de objetos completos.
**Pruebas:** página 3/sort descendente/query/plantilla/scroll X e Y → ficha → volver; búsqueda y contactos de Persona; recursos con contexto; reload y enlaces compartidos; parámetros inválidos; registro desaparecido y página fuera de rango. Texto con/sin tildes, Unicode compuesto, ñ/n distintas, espacios y puntuación técnica. Filtro archivadas y limpiar.
**Salida:** contexto restaurado por dimensiones independientes, búsqueda equivalente coherente sobre campos acordados, filtros visibles y limpieza predecible. **Reversión:** helper y adopciones en commits acotados; volver a parsers anteriores sin eliminar preferencias de columnas; retirar parámetros nuevos sin alterar datos.
### L04 — Adopción transversal y cierre documental
**Objetivo:** aplicar capacidades aprobadas a consumidores equivalentes restantes, O01 y actualización normativa; preservar excepciones T/R/S. Medir riesgos R01 antes de cualquier proyecto de rendimiento/backend.
**Componentes:** sublistados de persona/puesto/activo, configuraciones/catálogos/accesos y rama de dashboard; revisar inventario A–E con checklist y excepciones. No reescribir timelines, Select de enums o proveedores para aparentar uniformidad.
**Dependencias:** L02/L03 verificadas; fixtures de dominio/SMB/configuraciones y permiso administrativo en entorno específico para cerrar pendientes. Cambios posteriores a documentos normativos requieren su tarea autorizada, no se realizan en L01.
**Riesgos:** propagar un patrón fuera de su contexto, quitar información de soporte por reducir ruido, confundir retiro de catálogo con borrado. Validar por familia y no por sustitución textual masiva.
**Pruebas:** matriz de siete tamaños en principales; muestras pobladas/vacías de configuraciones, sublistados y selectores; teclado/foco, es/en, ausencia `—`, filtros/acciones/relaciones y estados. Confirmar contratos documentados V01.3/V01.4 y hallazgos resueltos sin ampliar artificialmente su alcance.
**Salida:** adopción inventariada, excepciones justificadas, evidencia enlazada, DESIGN/patrones/componentes/rutas reconciliados; pendientes de permisos/datos explícitos. **Reversión:** adopción por familias, no tocar vendor ni backend, revertir integración de la familia sin retirar APIs usadas por otras.
## 17. Criterios de aceptación
### Para L01
- Existe este informe con 19 secciones, inventario de colecciones más allá de List/Table, trazabilidad a componentes reales y clasificación de excepciones.
- Matriz de 23 variantes por siete resoluciones ejecutada; se distinguen capturas, DOM, código y datos pendientes.
- V05/V07/V08/V12 tienen pasos, resultados y evidencia, continúan abiertos y no se duplican. No se altera el estado de V01/V02/V03/V06/V09.
- Hay alternativas, cinco wireframes, reparto de responsabilidades y plan sin asignar la misma corrección a dos entregas.
- Único archivo versionado nuevo: este informe. Artefactos excluidos de Git; sin sesiones ni credenciales copiadas; instancia reutilizada conservada.
### Para aceptar futuras mejoras
- Identidad y acciones esenciales visibles incluso tras personalizar; nombres de controles comprensibles tanto visual como programáticamente.
- En 320×640, un picker poblado permite ver y alcanzar sus opciones y confirmar/cancelar, con cuerpo de tabla de altura positiva; ayuda y pie no lo colapsan. Acordar cuántas filas completas debe mostrar cada composición antes de implementarla.
- Cambio de carga a datos mantiene estructura; refetch indica estado y error posterior no aparenta datos recientes.
- Volver a listado conserva búsqueda/filtros/sort/página y restaura foco/scroll según contrato; se ajusta si desaparece un registro.
- Normalización respeta ñ y campos de dominio; paginación remota no se filtra localmente ni muestra totales inventados.
- Capturas y pruebas de interacción reales, es/en, teclado/foco y regresión responsive; verificaciones técnicas dirigidas y suites transversales según alcance de código. No declarar WCAG ni rendimiento sin campaña suficiente.
## 18. Evidencias y limitaciones
### Índice de evidencias
Directorio local ignorado: [.artifacts/playwright/listados-l01](../.artifacts/playwright/listados-l01/). Los PNG y snapshots no se versionan; al clonar solo Git no estarán disponibles. El informe conserva pasos, medidas y conclusiones; el índice temporal añade hash/dimensiones para inspeccionar los archivos locales.
| Hallazgo/escenario | Ruta o contexto | Resolución | Captura / medición |
| --- | --- | --- | --- |
| Matriz principal | Rutas A01–A18 y D01–D05 indicadas en sección 6. | Siete tamaños. | `matrix-results.json`; `matrix-*-1920.png`, `matrix-*-320.png` e intermedias seleccionadas. |
| L01-01 | Activos/recursos/catálogos/cuentas; nueva plantilla → añadir campos. | 320×640; comparación 1920×1080. | `matrix-assets-320.png`, `matrix-resources-320.png`, `matrix-asset-catalog-320.png`, `field-picker-capacity-320.png`, `field-picker-capacity-scrolled-320.png`, `picker-capacity-results.json`. |
| L01-02 | Perfiles → nuevo → seleccionar puesto → columnas. | 1440×900. | `option-identity-hidden.png`, `extra-final-results.json`. |
| V05 | Activos → ficha original → Atrás; personas → detalle → volver. | 1366×768, 390×844; personas 1440×900. | `v05-before.png`, `v05-detail.png`, `v05-after.png`, `v05-mobile-return.png`, `flows-context-results.json`, `extras-results.json`. |
| V07 | Búsquedas de inventarios/personas/catálogos. | 1440×900. | `v07-unaccented.png`, `v07-accented.png`, `flows-search-results.json`. |
| V08 | Activos → Columnas → ocultar Sede → cerrar → reload. | 1440×900 y 320×640. | `v08-columns-1440.png`, `v08-columns-320.png`, `flows-columns-results.json`. |
| V12 | Activos, GET inicial/refetch retenido y liberado. | 1440×900. | `v12-initial-held.png`, `v12-recovered.png`, `v12-background-held.png`, `states-results.json`. |
| Error recuperable | Activos, 503 simulado inicial y posterior. | 1440×900. | `simulated-initial-error-503.png`, `simulated-background-error-503.png`, `states-results.json`. |
| Vacío y criterios | Recursos con GET vacío simulado / filtro sin coincidencias. | 1440×900. | `empty-resources.png`, `no-results-resources.png`, `states-results.json`. |
| Registro desaparecido | Ficha de activo, GET transformado sin seleccionado. | 1440×900. | `selected-disappears.png`, `states-results.json`. |
| L01-03 | Sedes → mostrar archivadas → buscar → limpiar. | 1440×900. | `site-clear-keeps-archived.png`, `flows-search-results.json`. |
| L01-04 | Nuevo puesto paso 2; selector de puesto del perfil. | 1440×900. | `workstation-step-2.png` y `.txt`, `option-picker-1440.txt`. |
| Contrato recursos | Selección integrada → ficha/historial explícito → cerrar; apertura estrecha y Escape. | 1920×1080 y 1024×768. | `resource-inline.png`, `resource-explicit-dialog.png`, `resource-overlay-1024.png`, `flows-context-results.json`. |
| Selectores locales | Puesto: activos; perfil: puesto; plantilla: campos. | 1440×900, 320×640; opciones 390×844. | `asset-picker-*`, `option-picker-*`, `field-picker-*`, `pickers-final-results.json`. |
| Selector remoto real | Activo → asociar dominio → elegir dominio, búsqueda/vacío. | 1440×900 y 320×640. | `domain-remote-final-1440.png`, `domain-remote-final-320.png`, `remote-final-results.json`. |
| Sublistados | Persona → asignaciones; puesto → perfiles. | 1440×900 y persona 390×844. | `person-assignments-*`, `workstation-related-1440.png`, `pickers-final-results.json`. |
| Variantes catálogo/acceso | Campos/relaciones de activo, campos de recurso, permisos/entornos. | 1440×900. | `catalog-fields.png`, `catalog-relations.png`, `resource-fields.png`, `access-permissions.png`, `access-environments.png`. |
| Permisos | `/settings/users`, usuario ordinario. | 1440×900. | `users-unprivileged.png`, `extras-results.json`, GET 403. |
| Inglés | Personas, activos y configuración de columnas. | 1440×900 y activos 320×640. | `people-english-1440.png`, `columns-english-final-1440.png`, `assets-english-final-320.png`, `extra-final-results.json`. |
| R01 / volumen | Inventario, respuestas sintéticas 30/300/1000. | Navegador headless, tamaño declarado por script. | `benchmark-results.json`, `benchmark-network.json`; sin extrapolar a producción. |
`evidence-index.json` relaciona las **101 capturas** conservadas, rutas, resoluciones, IDs, dimensiones y SHA-256; `source-inventory.json` y `source-classification.json` documentan cobertura estática. Snapshots `.txt` acompañan capturas. Los registros de red solo guardan método/ruta/estado/tamaño, excluyen endpoints de autenticación y no incluyen cookies ni cuerpos privados.
### Reproducción y comprobaciones realmente ejecutadas
La preparación permanente sigue [Playwright MCP](playwright-mcp.md). Para reproducir **esta sesión local**, verificar primero que continúa activo el preparador original y su sesión sintética, sin imprimir su contenido. La instrumentación usa esa sesión en montaje de solo lectura y lee el origen/compañía de `environment.json`; la ruta temporal de sesión está localizada en `audit.py`. Si ya no existe, no presentar los scripts como autónomos: preparar una instancia desechable con el procedimiento documentado y autorización correspondiente, actualizar esas referencias efímeras y regenerar los DTO contra el catálogo del nuevo entorno.
Se ejecutaron el escaneo AST (`inventory.mjs`), validación Zod de `samples.ts`, matriz (`audit.py matrix`), flujos (`flows.py search`, `columns`, `context`), estados (`states.py`), pickers (`pickers.py`), verificaciones de permiso/variantes (`extras.py`), selector remoto (`remote-final.py`), columnas/idioma (`extra-verifications.py`), capacidad de filas (`picker-capacity.py`) y muestra de volumen (`benchmark.py`). Todos son instrumentación ignorada, no scripts nuevos de producción ni dependencias instaladas. Se inspeccionaron directamente PNG representativos además de los snapshots y rectángulos.
Algunos primeros intentos de la instrumentación fallaron por nombres accesibles exactos, esperar un workspace en la página de permiso denegado o cambiar idioma antes de una navegación que lo reinicializaba. Se conservaron sus resultados `PENDING`/`FAILED`, **no se contaron como fallos de Metis** ni como pruebas superadas. Los escenarios de dominio, permiso e inglés tienen reproducciones finales específicas en `remote-final`, `extras` y `extra-final`. La primera medición de capacidad de picker falló al consultar `document` fuera de `page.evaluate`; se corrigió el instrumento y se repitió. El 503 de los estados es deliberado, no incidente real del servidor.
Los archivos `*-console.json` recogen cero errores y cero advertencias en las ejecuciones registradas. Los 503 simulados y 403 real se documentan en red/resultados aunque no generen error de consola. La conexión MCP inicial sin la sesión reutilizada redirigió al login; no se confundió con navegación autenticada y se reemplazó por el cliente Docker con sesión montada. No se ocultan estos límites con una declaración general de «todas las pruebas pasan».
### Pendientes explícitos
- Usuarios administrativos: fixture y permiso adecuados en otro entorno; no inspeccionada su tabla poblada ni siete resoluciones de esa pantalla.
- Infraestructura: router/switch/AP/NAS y periférico con ethernet+wifi y varias IP, relaciones válidas; actual recorrido vacío no demuestra legibilidad de esas celdas pobladas.
- Directorios/SMB: dominio, pertenencias vigentes/finalizadas, host/publicación y más de una página de cursor; C04 solo vacío, C05 y relaciones pobladas solo código.
- Configuraciones de correo/carpeta/certificado/aplicación/impresora, teletrabajo/procesos y accesos: entidades asociadas válidas y revisiones para comprobar acciones/errores/paginación poblados. Sus shells vacíos sí están medidos.
- Clic de guardado, bajas y operaciones masivas no ejecutados por el alcance diagnóstico y la prohibición de escrituras.
- Contraste instrumental, lectores de pantalla, zoom/reflow exhaustivo, más motores y dispositivos físicos no ejecutados. Los siete tamaños son emulación de viewport, no siete equipos.
- Las mediciones de volumen no sustituyen un profiler ni pruebas de red/servidor. No se añadieron fixtures persistentes para cerrar artificialmente esas lagunas.
Validación documental: comprobación de rutas de código y documentos, enlaces/anclas, existencia/dimensiones de evidencias, diff y ausencia de cambios funcionales. No se ejecutaron typecheck/lint/test/build de producción porque únicamente se añadió documentación; sí se ejecutaron los instrumentos descritos. La comprobación final HEAD del frontend devolvió 200 y la sesión preexistente sigue con permisos 0600. Se conservan preparador y servicios ajenos; los clientes de navegador temporales creados por L01 se cierran mediante su contexto.
## 19. Skills consultadas y aplicación concreta
| Skill leída | Ruta de SKILL.md | Aplicación en esta auditoría |
| --- | --- | --- |
| **ux-tables**, principal | `.agents/skills/ux-tables/SKILL.md` | Comparación de filas/columnas, identidad, valores numéricos, acciones visibles, densidad, selección y distinción entre tabla, lista y cronología. |
| ux-search | `.agents/skills/ux-search/SKILL.md` | Campos explícitos, búsqueda conocida/exploratoria, filtros activos, cero coincidencias y normalización diferenciada de ordenación. |
| ux-empty-states | `.agents/skills/ux-empty-states/SKILL.md` | Separación de primera alta, sin criterios y error; CTA existente útil sin duplicación decorativa. |
| ux-navigation | `.agents/skills/ux-navigation/SKILL.md` | Retorno de ficha, contexto, paginación y contratos distintos de activos/recursos. |
| ux-microcopy | `.agents/skills/ux-microcopy/SKILL.md` | Etiquetas de acción frente a sustantivo del rango y correspondencia texto visible/nombre accesible. |
| frontend-design | `.agents/skills/frontend-design/SKILL.md` | Jerarquía y composición intencional dentro del sistema existente; no rediseño estético ni fuentes nuevas. |
| shadcn | `.agents/skills/shadcn/SKILL.md` | API real Base UI/Vega, composición de controles/dialogs, semántica y reutilización sin regenerar vendor ni añadir bibliotecas. |
| vercel-composition-patterns | `.agents/skills/vercel-composition-patterns/SKILL.md` | Separación módulo/presentación, composición frente a flags por entidad; referencia de arquitectura sobre evitar proliferación de booleanos. |
| vercel-react-best-practices | `.agents/skills/vercel-react-best-practices/SKILL.md` | Estado derivado, consultas compartidas, coste medible y evitar efectos/memoización de rutina; referencia de estado derivado sin efecto. |
Se consultaron las referencias pertinentes de composición y diferencias Base UI/Radix, no se ejecutaron generadores ni comandos de instalación. `ui-ux-pro-max` está disponible, pero no se aplicó: los problemas concretos quedaron cubiertos por las skills especializadas, los contratos de Metis y la evidencia; no se ejecutó por rutina. `web-design-guidelines` y los auditores especializados a los que algunas skills delegan no están disponibles en el catálogo de esta sesión; no se instalaron ni se afirmó haberlos usado. La referencia `ux-tables/docs/orchestration-policy.md` no existe en la instalación localizada; se trabajó con el SKILL y las fuentes realmente accesibles.
Precedencia aplicada: solicitud L01 y restricciones de AGENTS → DESIGN/patrones especializados → APIs reales → recomendaciones de skills. Se descartó ocultar primarias solo en hover, trasladar la selección al final, imponer 16 px/44 px a todos los controles compactos, cambiar fuentes/colores, introducir SWR/Next o reemplazar tablas por tarjetas. Ninguna recomendación genérica justifica modificar vendor, instalar dependencias o declarar una validación visual que no se ejecutó.

View File

@ -533,6 +533,24 @@ El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y
## Selección de activos en diálogo
### Capacidad operativa e identidad — L02.1
Los selectores de campos, opciones y activos propagan flex/min-height 0 hasta la tabla.
`EntityTable layout="fill"` usa herramientas/paginación compactas, rango único y mínimo
operativo propio; `layout="content"` conserva la presentación de listados. Los controles
internos `EntityTableConfiguration` y `EntityListPagination` reciben la presentación
`picker` o `full`, con `full` como default. No conocen entidades ni queries.
`PickerHelp` recibe label/title/description y compone Popover de Base UI junto al pie.
Permite consultar sección e instrucciones completas sin consumir el alto de las filas.
`OptionPickerList` protege selection/name; sus etiquetas incluyen `entity(count)`, separado
del título de acción. `EntityTable.entityLabel` acepta ese callback o la cadena anterior.
Campos y perfiles aportan las formas i18n; los selectores no adquieren persistencia nueva.
`AssetSelect presentation="action"` conserva el nombre accesible de su contenido visible;
la presentación de campo incluye etiqueta y valor. Véanse presupuesto, pruebas y límites en
[selectores y accesibilidad L02.1](selectores-accesibilidad-l02-1.md).
`AssetSelect`, contrato público de `@metis/assets/client`, sustituye los desplegables de activos de formularios. Su disparador muestra el valor y conserva la acción de alta rápida. El modo único recibe un UUID y devuelve un UUID; el múltiple recibe una colección, devuelve una colección confirmada y declara `maxSelection`. Los consumidores aportan opciones permitidas, bloqueos, textos y sedes. Las reglas de negocio siguen en cada formulario.
`AssetPickerDialog` carga inventario y catálogo mediante sus queries existentes. Conserva una selección provisional hasta confirmar; Cancelar, Escape y cerrar no modifican el formulario padre. No invoca endpoints de escritura. El diálogo muestra estados de carga/error y reintento, contador de seleccionados, controles accesibles y confirmación deshabilitada si hay selecciones inválidas o se supera el límite. Las selecciones sobreviven a búsqueda, pestañas y paginación. No navega ni cambia filtros URL de la pantalla subyacente.

View File

@ -148,6 +148,19 @@ No debe crecer, encogerse ni desplazar el pie por cada elección del usuario.
## Diálogos de selección PickerList
Implementación y evidencias de capacidad: [L02.1](selectores-accesibilidad-l02-1.md).
En selectores locales, mantener `min-h-0` en wrappers flex y no imponer `shrink-0`
a toda la cadena de resultados. `EntityTable layout="fill"` reserva su presupuesto
operativo con herramientas y paginación compactas; no sumar mínimos independientes en
RadioGroup/Tabs. Verificar `clientHeight` y clipping de filas, no solo el alto del modal.
El objetivo en 320×640 es mostrar dos opciones ordinarias completas; si el contenido
extenso lo impide, documentar el presupuesto y demostrar acceso íntegro mediante scroll.
La ayuda extensa puede utilizar `PickerHelp` junto al pie: conserva instrucciones y
destino en un Popover accesible sin reducir las filas al desplegarlo. No ocultar información
obligatoria ni crear focus traps o listeners Escape manuales. En ventanas muy cortas,
el scroll de contingencia del cuerpo conserva acceso a controles; no encadenar su gesto
con el desplazamiento de resultados.
- Todos utilizan `FormDialog layout="picker"` y `EntityTable layout="fill"`: altura estable
adaptada a la ventana, independiente de búsquedas, carga y número de resultados. La tabla
ocupa el espacio restante y mantiene desplazamiento interno con espacio reservado para
@ -158,6 +171,9 @@ No debe crecer, encogerse ni desplazar el pie por cada elección del usuario.
- La columna `selection` es la primera, antes del nombre: anchura ajustada al checkbox o
radio, poco espacio hasta el nombre y cabecera visualmente oculta con nombre accesible.
No se ordena, no se oculta y no se ancla a la derecha como las acciones.
- Proteger también la identidad principal con `hideable:false`. Las preferencias
incompatibles no pueden dejar radios/casillas sin un nombre visible. Los rangos utilizan
el sustantivo de la entidad, con singular/plural es/en, nunca el título «Seleccionar…».
- Pulsar una zona no interactiva de una fila disponible alterna su checkbox o selecciona
su radio. Reutilizar `onRowClick` de la tabla; los controles internos ejecutan solo su
propia acción, sin duplicar cambios. Respetar filas deshabilitadas y navegación por teclado.

View File

@ -59,6 +59,15 @@ El cero y los valores reales permanecen visibles; los booleanos de negocio se pr
## Común y específico
En selectores locales, `EntityTable layout="fill"` utiliza la composición compacta de
[L02.1](selectores-accesibilidad-l02-1.md): búsqueda/configuración agrupadas, un rango en
el pie y paginación con anterior/siguiente, página actual y tamaño etiquetado. En cajas
estrechas se ordena mediante las cabeceras; el selector adicional de orden aparece cuando
hay capacidad. Esta excepción no modifica las bandas de los listados generales.
`entityLabel` admite una cadena o función del total filtrado para que el consumidor
aporte singular/plural traducidos. La identidad mínima se protege por columna con
`hideable:false`; no se obliga a mostrar todas las columnas ni se borran preferencias.
Común: distribución, cabecera, cuatro bandas, márgenes, altura base de filas, búsqueda, limpieza, contador, columnas, ordenación, paginación, accesibilidad y estados.
Específico: título, subtítulo, ámbito, alta y acciones globales, campos buscables, filtros, columnas, formatos, acciones de fila, avisos y destino de ficha. Las columnas conservan la información funcional de cada pantalla. Licencias y aplicaciones continúan dentro del listado Recursos; no crear pantallas para entidades que no existen.

View File

@ -0,0 +1,162 @@
# L02.1 — Selectores tabulares, capacidad móvil y accesibilidad
Fecha: 22 de octubre de 2026. Implementación derivada de [L01](auditoria-listados-l01.md), limitada a selectores, identidad mínima y microcopy. No implementa L02.2 ni V05/V07/V08/V12.
## 1. Resumen de la implementación
Los selectores locales siguen utilizando FormDialog, FormBody y EntityTable. Se corrigió su cadena flex y se compuso una barra de herramientas y paginación apropiadas para el espacio de selección. Los listados generales conservan su presentación. La altura exterior, selección provisional, filtros, ordenación por cabecera y operaciones de confirmar/cancelar se mantienen.
La ayuda de campos utiliza `PickerHelp`, compuesto con Popover de Base UI/shadcn: un botón junto al pie permite consultar sección de destino e instrucciones completas, también con teclado. No hay focus trap ni listener Escape nuevos.
## 2. Hallazgos corregidos y alcance
| Hallazgo | Resultado y límites |
| --- | --- |
| L01-01 | Corregido el colapso de los selectores tabulares comprobados: campos de activos/recursos, opciones de perfil y activos del puesto. Los selectores remotos se verifican por separado. No se declara corregida la densidad inicial de **listados generales** señalada bajo el mismo ID en L01. |
| L01-02 | Selección e identidad protegidas en OptionPickerList; nombre protegido también en DashboardDetailsDialog y AssetRelatedInformation. No se hacen obligatorias todas las columnas. |
| L01-04 | AssetSelect en presentación de acción conserva como nombre accesible su contenido visible; la presentación de campo incluye etiqueta y valor. Rangos de selectores usan nombres de entidades y pluralización es/en según el número filtrado. |
Las medidas y reproducciones originales de L01 se conservan. V05, V07, V08 y V12 permanecen pendientes; el selector de columnas sigue sin búsqueda/grupos/restauración. No se modifica el estado de V01/V02/V03/V06/V09 ni otros hallazgos.
## 3. Causa técnica del colapso
La reproducción previa mediante MCP confirmó en 320×640:
| Nivel | Distribución efectiva antes del cambio |
| --- | --- |
| DialogContent | Flex columna; altura exterior 608 px, máximo 608; overflow hidden. |
| Contenido del diálogo | Flex columna, grow/shrink 1, min-height 0; alto 509 px y overflow hidden. |
| FormBody | Flex columna, grow/shrink 1, min-height 0, overflow auto; clientHeight 332 y scrollHeight 604. |
| EntityTable fill | Flex columna, grow 1, **shrink 0 impuesto por el padre**, mínimo 384 px; clientHeight 382, scrollHeight 387. |
| TableContainer | Grow/shrink 1, min-height 0, overflow auto; **clientHeight 0**, scrollHeight 950. |
| Pie de acciones | Alto aproximado 157 px. |
El mínimo de tabla no garantizaba filas: sus bandas no reducibles lo consumían. La ayuda y destino añadían altura al cuerpo, pero no eran la única causa. Desplazar FormBody no recuperaba el contenedor colapsado. `before-results.json` conserva display, dirección, grow/shrink, mínimos/máximos, overflow y geometría de cada ancestro.
## 4. Cambios de composición y presupuesto
- FormBody en contexto picker deja de imponer shrink 0 a todos sus hijos; conserva min-height 0, margen para foco y scroll exterior de contingencia. No cambia los modos content/stable ni su `contentLayout="fill"`.
- OptionPickerList, AssetPickerList y el wrapper Tabs de AssetList propagan min-height 0. La tabla se encarga de su presupuesto, en lugar de sumar mínimos independientes en cada wrapper.
- EntityTable `layout="fill"` agrupa búsqueda y configuración en una barra. En contenedores inferiores a 32 rem, el selector de orden redundante se omite; las cabeceras continúan permitiendo ordenar. Columnas permanece accesible. El listado general `layout="content"` conserva las bandas previas.
- El picker muestra un único rango en el pie, sin duplicarlo arriba. La paginación incluye tamaño de página etiquetado, anterior/siguiente y página actual/total. No se elimina la navegación de páginas ni se repagina una respuesta remota.
- Mínimo de tabla local: 18 rem, presupuesto para herramientas compactas, cabecera y opciones. No es altura fija de filas ni reducción de tipografía/controles. Las filas siguen creciendo por su contenido.
- FormActions en contexto picker distribuye Cancelar/Confirmar en la misma fila cuando caben; el alta relacionada conserva una fila propia en móvil. Cabecera y pie siguen fuera del scroll de resultados.
- PickerHelp conserva instrucciones completas y destino fuera del flujo que reparte las filas. Abrirlo no altera la altura exterior; su contenido extenso tiene desplazamiento limitado al espacio disponible.
En las ventanas ordinarias probadas solo los resultados necesitan scroll vertical. En ventanas de altura insuficiente o texto ampliado, el mínimo operativo permite scroll adicional de FormBody para alcanzar herramientas/resultados; `overscroll-contain` evita encadenar el gesto hacia el otro scroll. Este fallback es deliberado, no una promesa de dos filas en toda altura o longitud de texto.
## 5. Política de identidad mínima
`hideable:false` expresa la política existente. En OptionPickerList se protegen selection y name; description sigue siendo configurable. FieldPickerList y AssetPickerList ya protegían selección/nombre. La revisión de consumidores detectó además nombre ocultable en el resumen de compañías del dashboard y en los perfiles asociados a un activo: ambos se corrigen con la misma propiedad.
El hook de tabla ya impone las columnas protegidas sobre una preferencia `false`; no se cambia el formato de almacenamiento. IDs desconocidos no crean columnas ni rompen el render. Las preferencias de otros listados no se borran y los selectores sin clave de persistencia siguen sin ella. Las pruebas existentes de persistencia se amplían con un ID desconocido y las pruebas MCP comprueban que el checkbox de identidad está deshabilitado.
## 6. Microcopy y traducciones
- AssetSelect de acción deja de sobrescribir el texto visible con `aria-label={label}`. «Seleccionar activos» se reconoce por su propio contenido; el signo + es decorativo. No se sustituye globalmente «Añadir» por «Seleccionar».
- La presentación de campo mantiene contexto y valor en el nombre accesible, por ejemplo «Ordenador principal: Equipo 2».
- `EntityTable.entityLabel` admite la cadena anterior o una función del número filtrado. Es una capacidad presentacional, sin DTO ni conocimiento de entidades.
- `OptionSelectionField`/OptionPickerDialog/OptionPickerList reciben `labels.entity(count)`, independiente del título de acción. El módulo de perfiles aporta puestos/personas/cuentas y sus formas es/en.
- Campos y activos aportan también singular/plural mediante i18n. El rango compacto es «1–1 de 1 puesto» / «1–1 of 1 workstation»; las pruebas distinguen entidad y acción. El formato general «Mostrando…» no cambia.
- La ayuda añade «Sección y ayuda» / «Section and help». No se introducen textos de negocio literales en los componentes genéricos.
## 7. Componentes y consumidores afectados
| Área | Archivos/responsabilidad |
| --- | --- |
| Composición propia compartida | FormDialog/FormBody/FormActions, EntityTable, EntityTableConfiguration y EntityListPagination. Defaults de listados generales conservados; presentación picker explícita en controles internos. |
| Selección y ayuda | FieldPickerDialog/List, OptionSelectionField, OptionPickerDialog/List y nuevo PickerHelp. |
| Activos | AssetSelect, AssetPickerList, AssetList, selectores de campos de plantilla y traducciones. |
| Recursos | Adaptadores de FieldPickerDialog/List y traducciones de campos. |
| Compañías | Etiquetas de selección de perfiles, identidad de dashboard y sublistado de perfiles del activo. |
| Pruebas | Tests SSR de selección/visibilidad/AssetSelect, `scripts/picker-ui.test.py` y `picker-browser-fixture.tsx`, importada únicamente por Vite desde el guion de navegador. |
No se cambian vendor, rutas, consultas HTTP, Sequelize, autenticación, permisos, paleta, fuentes ni arquitectura V01. La fixture React no es una ruta ni un consumidor de producción; solo monta componentes reales con datos temporales para medir geometría.
## 8. Mediciones antes y después
| Escenario 320×640 | Antes | Después |
| --- | --- | --- |
| Campos de plantilla de activo | Modal 608 px; filas clientHeight 0. | Misma altura exterior; filas clientHeight 184 px con el catálogo real sintético y tres acciones. |
| Campos de plantilla de recurso | Misma composición susceptible de colapso. | Filas clientHeight 184 px en el recorrido real. No se atribuye una medida anterior no tomada a este consumidor. |
| Fixture de campos, 0/1/2 opciones cortas | No forma parte de L01. | Filas clientHeight 256 px; se ven 0/1/2 opciones completas respectivamente. Con doce opciones cortas, la muestra de campos ofrece 228 px y tres filas completas; la de opciones, 324 px y cinco. |
| Activos desde puesto | No se atribuye aquí una medida anterior no tomada. | Contenedor 212 px con doce activos y dos opciones completas; la primera deshabilitada conserva su explicación. Se verifican seis resoluciones, cancelación y confirmación. La separación entre pestañas y tabla se ajusta solo en selectores estrechos. |
| Nombres/descripciones extensos | L01 mostraba filas en DOM sin superficie útil. | Contenido íntegro y scroll utilizable; una opción puede superar por sí sola la ventana de resultados. No se ocultan descripciones para alcanzar una cifra. |
Objetivo de dos filas: se verifica con opciones cortas en los seis tamaños solicitados. En el catálogo real, las dos primeras opciones necesitan aproximadamente 206 px más 40 px de cabecera; el contenedor de 184 px deja una completa y parte de la segunda. Se adopta ese presupuesto como alternativa fundamentada: conserva tres acciones, contador, búsqueda, selección y ayuda, y permite recorrer íntegramente las opciones. No se declara cumplido un mínimo universal de dos opciones de longitud arbitraria.
Los JSON diferencian altura del modal, clientHeight/scrollHeight del scroller efectivo, clipping por ancestros, filas completas, foco, pie y overflow global. El selector remoto utiliza su contenedor real, no el wrapper horizontal de Table como sustituto de la superficie visible.
## 9. Pruebas funcionales
La suite MCP usa unittest y el cliente stdio existente de `scripts/visual-audit.py`, sin framework nuevo. Valida 0/1/2/12 opciones, resize con diálogo abierto, cero coincidencias, identidad, selección por radio/checkbox y fila, opciones deshabilitadas, paginación, confirmación y cancelación provisional.
Los flujos reales recorren plantilla de activo y de recurso, activos desde nuevo puesto, puesto desde nuevo perfil y selector de dominio. Cuando faltan sedes/puestos, el guion sustituye únicamente GET con relaciones sintéticas coherentes: una sede, doce activos y un puesto con ordenador y miembros consistentes. No se guardan formularios ni se realizan altas de negocio durante estos recorridos.
Los datos del catálogo y las relaciones de dominio proceden de la instancia desechable del preparador existente. La fixture de RemoteOptionPicker prueba su contrato con páginas aportadas por el consumidor; no demuestra por sí sola paginación de servidor ni publicaciones SMB.
## 10. Pruebas de accesibilidad
Se comprueban foco en búsqueda al abrir, Space en radio, nombres accesibles, columnas protegidas, contador de entidad, Escape, cancelación y retorno a disparador. En plantillas se abre y cancela el alta rápida anidada sin cerrar picker ni formulario padre. La ayuda es un Popover operable por teclado; PageDown desplaza las instrucciones extensas y Escape cierra esa capa conservando el picker.
Las comprobaciones de geometría no se sustituyen por snapshots. Se prueba altura reducida 320×480 y ampliación de texto CSS al 200 %; esta última no equivale al zoom nativo del navegador ni certifica WCAG. Lector de pantalla, contraste exhaustivo y zoom nativo 400 % no se acreditan en esta entrega.
## 11. Evidencias y reproducción Playwright
Directorio ignorado: [.artifacts/playwright/l02-1](../.artifacts/playwright/l02-1/). Incluye capturas antes/después, snapshots, medidas por ejecución (`measurements*.json`), consola y resultados técnicos. `evidence-index.json` relaciona archivos, dimensiones y SHA-256. Las capturas relevantes se abren para inspección visual antes del cierre.
La sesión de L01 se reutilizó para las reproducciones previas, pero caducó durante la instrumentación. Se comprobó la redirección al acceso y se preparó una instancia nueva mediante `scripts/visual-audit.py`; no se recuperaron credenciales ni se cambió seguridad. Las pruebas permanentes validan que la sesión proporcionada siga autorizada antes de navegar.
En otro entorno, seguir [Playwright MCP](playwright-mcp.md), mantener el preparador abierto y utilizar su ruta privada de sesión:
```bash
PYTHONDONTWRITEBYTECODE=1 METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
python3 scripts/picker-ui.test.py
```
No copiar sesiones ni credenciales a Git. El script no inicia servicios ni escribe entidades. Al cierre de L02.1 se retira únicamente la instancia visual nueva que se preparó para estas pruebas; la sesión caducada y las instalaciones previas no se modifican. Requiere Vite de desarrollo para importar la fixture de componentes. Las regresiones de asistentes/foco/editor de recursos utilizan `scripts/visual-audit-ui.test.py` con una carpeta de evidencia separada.
## 12. Resultados técnicos y regresiones
Los logs distinguen comprobaciones dirigidas, globales y guiones de navegador. Los intentos iniciales con importación incorrecta, sesión caducada o localizador que confundía el Popover con el diálogo se conservan y no se presentan como defectos de Metis ni pruebas correctas.
Los fallos globales anteriores se contrastan con [V01.2](navegacion-responsive-v01-2.md) y [V01.4](recursos-responsive-v01-4.md): calendar del vendor en typecheck, reglas/formato ajenos en lint/check y dos expectativas antiguas de compañías en test. No se modifica ese código para conseguir un resultado global verde. La ejecución global de tests utiliza MariaDB nueva y desechable, exclusivamente `metis_desk_test`; no utiliza ni limpia la instancia visual o bases existentes.
Los resultados finales por comando y la regresión de navegador se registran en la tabla de cierre siguiente; un comando global fallido no se sustituye por el resultado de una prueba dirigida.
| Comprobación ejecutada | Resultado final |
| --- | --- |
| `pnpm --filter @repo/rdx-ui test` | Correcto: 11 archivos de prueba. |
| `pnpm --filter @metis/assets test` | Correcto: 27 archivos. Test de AssetSelect repetido tras completar su fixture tipada. |
| `pnpm --filter @metis/resources test` | Correcto: 11 archivos. |
| Tests dirigidos de perfiles y ficha de puesto | Correctos: dos archivos, incluida pluralización real de puestos/personas/cuentas es/en. |
| Typecheck dirigido de rdx-ui, activos, recursos y compañías | Correcto en los cuatro paquetes. |
| Typecheck de web | Correcto. |
| Biome dirigido | Correcto: 34 archivos propios TS/TSX/JSON, sin errores ni avisos. |
| ESLint de espaciado dirigido | Correcto sobre los archivos propios TS/TSX; se corrigieron tres separaciones entre sentencias sin cambios de comportamiento. |
| `pnpm typecheck` | Falla en vendor `calendar.tsx:165`, `locale` y exactOptionalPropertyTypes, ya documentado. Vendor intacto. |
| `pnpm lint` | Falla: 15 errores, un aviso y cuatro informaciones ajenos, coincidentes con el estado anterior. |
| `pnpm check` | Falla: 168 errores, un aviso y cuatro informaciones globales previos. Los archivos propios se comprueban separadamente. |
| `pnpm test`, MariaDB aislada | Falla en compañías: expectativas antiguas «Personas (1)» y «Esta persona no tiene acceso a Metis Desk»; 7 tareas correctas de 8. No acredita las suites que Turbo interrumpe. El contenedor exclusivo se retira en finally. |
| `pnpm build` | Correcto, dos tareas. Persiste el aviso de chunk web superior a 500 kB. |
| `python3 scripts/picker-ui.test.py` | **Ocho pruebas correctas**, 183,081 s en la ejecución integrada. Incluye seis tamaños, casos 0/1/2/12, catálogos, acciones, selección/foco y remoto. |
| Repetición ayuda/texto ampliado | Dos pruebas correctas; prueba de foco ampliado repetida con medición conservada por ejecución. Checkbox visible entre y=378–410 dentro de cuerpo y=250–538. |
| Aceptación adicional de activos/ayuda | Dos pruebas correctas, 54,319 s: dos opciones completas a 320×640 y PageDown de ayuda (scroll 336 px con foco dentro). |
| Regresión `visual-audit-ui.test.py` | **Tres pruebas correctas**, 162,850 s: geometría de asistentes de activo/recurso, retorno de foco y editor anidado de recursos. |
| Consola de las ejecuciones finales | Cero errores y cero advertencias. Ninguna escritura HTTP de negocio intentada en los recorridos. |
Se corrigieron errores del propio test (fixture múltiple sin maxSelection, inferencia de tuplas, localizador de output inerte y recuento de padres ocultos para accesibilidad). No eran fallos globales previos ni se dejaron supresiones. Las reproducciones finales con tipos estrictos y localizadores adecuados pasan.
## 13. Limitaciones y alcance pendiente
- Dos filas completas son un objetivo para opciones ordinarias, no para texto arbitrariamente largo. Se conserva todo el contenido con scroll, incluidos los catálogos reales de la muestra.
- La compactación de herramientas se limita a selectores. La altura inicial de datos en listados generales permanece pendiente bajo L01-01.
- Los cambios de identidad de dashboard y perfiles del activo se verifican en código y por el contrato común de visibilidad; no implican una auditoría completa de esas fichas.
- No se prueba escritura de altas rápidas ni gestión de publicaciones SMB; sí apertura/cancelación y las páginas aportadas al selector remoto.
- No hay certificación WCAG, prueba de lector de pantalla ni rendimiento de producción. Tampoco se declara resuelto V04 como proyecto independiente.
Skills: principal `ux-inputs-and-forms`; complementarias `ux-tables`, `shadcn`, `ux-navigation` y `ux-microcopy`. Se aplican a reservar superficie operativa, preservar borrador/confirmación y foco, reutilizar Popover/Dialog de Base UI y separar entidad de acción. `vercel-composition-patterns` orienta variantes genéricas sin flags por entidad; `vercel-react-best-practices` se consulta para estado derivado y fixtures sin efectos de sincronización. Las guías genéricas no autorizan cambiar tokens/vendor. Los auditores especializados ausentes no se presentan como ejecutados.
## 14. Preparación de L02.2
Quedan disponibles la superficie de resultados operativa, identidad protegida y `entityLabel(count)` traducible. L02.2 deberá tener encargo propio: V08 sigue pendiente de búsqueda/grupos/restauración de columnas y V12 de reserva de estructura durante carga inicial. V05/V07 mantienen su entrega independiente de contexto/búsqueda. No se adelantan esas implementaciones en L02.1.

View File

@ -297,6 +297,9 @@
"INVALID_SECTIONS": "Sections must have unique names and each field must belong to a section of this template."
},
"fieldPicker": {
"entity_one": "field",
"entity_other": "fields",
"helpLabel": "Section and help",
"destination": "Destination section: {{name}}",
"limit": "You have reached the template field limit. Deselect fields from this selection or remove fields from the template to add others.",
"open": "Select fields",
@ -451,6 +454,8 @@
"next": "Next"
},
"assetPicker": {
"entity_one": "asset",
"entity_other": "assets",
"single": "Select asset",
"multiple": "Select assets",
"select": "Selection",

View File

@ -297,6 +297,9 @@
"INVALID_SECTIONS": "Las secciones deben tener nombres únicos y cada campo debe pertenecer a una sección de esta plantilla."
},
"fieldPicker": {
"entity_one": "campo",
"entity_other": "campos",
"helpLabel": "Sección y ayuda",
"destination": "Sección de destino: {{name}}",
"limit": "Has alcanzado el límite de campos de la plantilla. Desmarca campos de esta selección o retira alguno de la plantilla para añadir otros.",
"open": "Seleccionar campos",
@ -451,6 +454,8 @@
"next": "Siguiente"
},
"assetPicker": {
"entity_one": "activo",
"entity_other": "activos",
"single": "Seleccionar activo",
"multiple": "Seleccionar activos",
"select": "Selección",

View File

@ -74,6 +74,32 @@ const listProps = {
onClearFilters: () => undefined,
};
test("la acción de selección conserva su texto visible como nombre accesible en es/en", async () => {
for (const language of ["es", "en"]) {
const text = language === "en" ? "Select assets" : "Seleccionar activos";
const html = await render(
language,
<AssetSelect
companyId={id(1)}
id="action-assets"
label={language === "en" ? "Add assets" : "Añadir activos"}
maxSelection={100}
mode="multiple"
onValueChange={() => undefined}
options={[]}
placeholder={text}
presentation="action"
value={[]}
/>
);
const trigger = html.match(/<button\b[^>]*aria-haspopup="dialog"[^>]*>[\s\S]*?<\/button>/)?.[0];
assert.ok(trigger);
assert.ok(trigger.includes(text));
assert.equal(trigger.includes("aria-label="), false);
}
});
test("selector: comparte búsqueda, pestañas por identidad y columnas del inventario en es/en", async () => {
for (const language of ["es", "en"]) {
const html = await render(

View File

@ -77,7 +77,7 @@ export function AssetPickerList({
) : (
<RadioGroup
aria-label={t("assetPicker.single")}
className="flex min-h-[28rem] flex-1 flex-col sm:min-h-96"
className="flex min-h-0 flex-1 flex-col"
onValueChange={(id) => {
if (typeof id === "string") {
onToggle(id);

View File

@ -9,21 +9,14 @@ import { AssetPickerDialog } from "./asset-picker-dialog";
/** La elección se confirma en un diálogo; cancelar conserva el valor del formulario padre. */
export function AssetSelect(props: AssetSelectProps) {
const { id, label, placeholder, options, disabled, action, invalid, describedBy, required } =
props;
const { id, label, disabled, action, invalid, describedBy, required } = props;
const { t } = useTranslation("assets");
const { presentation = "field" } = props;
const isAction = presentation === "action";
const [open, setOpen] = useState(false);
const requiredDescriptionId = required ? `${id}-required` : undefined;
const descriptionIds = [describedBy, requiredDescriptionId].filter(Boolean).join(" ");
const displayValue =
props.mode === "multiple"
? props.value.length
? t("assetPicker.selected", { count: props.value.length })
: placeholder
: (options.find((option) => option.value === props.value)?.label ??
(props.value || placeholder));
const displayValue = assetSelectionLabel(props, (count) => t("assetPicker.selected", { count }));
return (
<>
@ -36,7 +29,7 @@ export function AssetSelect(props: AssetSelectProps) {
aria-describedby={descriptionIds || undefined}
aria-haspopup="dialog"
aria-invalid={invalid || undefined}
aria-label={label}
aria-label={isAction ? undefined : `${label}: ${displayValue}`}
className="min-w-0 flex-1 justify-start whitespace-normal text-left data-[presentation=action]:flex-none data-[presentation=action]:justify-center data-[presentation=action]:text-center"
data-presentation={presentation}
disabled={disabled}
@ -82,3 +75,14 @@ export function AssetSelect(props: AssetSelectProps) {
</>
);
}
function assetSelectionLabel(props: AssetSelectProps, formatCount: (count: number) => string) {
if (props.mode === "multiple") {
return props.value.length ? formatCount(props.value.length) : props.placeholder;
}
return (
props.options.find((option) => option.value === props.value)?.label ??
(props.value || props.placeholder)
);
}

View File

@ -85,7 +85,7 @@ export function AssetList({
return (
<Tabs
className={`min-w-0 max-w-full ${layout === "fill" ? "min-h-112 flex-1 sm:min-h-96" : ""}`}
className={`min-w-0 max-w-full ${layout === "fill" ? "min-h-0 flex-1 gap-1 @min-[32rem]:gap-2" : ""}`}
onValueChange={onTemplateChange}
value={templateId ?? "all"}
>
@ -190,6 +190,9 @@ export function AssetList({
]}
columnVisibilityStorageKey={columnVisibilityStorageKey}
emptyMessage={t("noAssets")}
entityLabel={
layout === "fill" ? (count) => t("assetPicker.entity", { count }) : t("inventory")
}
filterKey={templateId ?? "all"}
filtersApplied={templateId !== null}
items={items}

View File

@ -25,6 +25,7 @@ export function CatalogFieldPickerDialog(props: {
title: t("fieldPicker.title"),
cancel: t("cancel"),
help: t("fieldPicker.help"),
helpLabel: t("fieldPicker.helpLabel"),
destination: t("fieldPicker.destination", {
name: props.sectionName ?? t("sections.ungrouped"),
}),

View File

@ -36,7 +36,7 @@ export function CatalogFieldPickerList({
select: t("fieldPicker.select"),
selectField: (name) => t("fieldPicker.selectField", { name }),
empty: t("fieldPicker.empty"),
fields: t("fields"),
fields: (count) => t("fieldPicker.entity", { count }),
search: t("search"),
}}
locale={i18n.language}

View File

@ -605,18 +605,24 @@
"empty": "Choose the profile workstation",
"description": "Select an active workstation in this company with an available main computer. If none exists yet, complete the workstation first.",
"select": "Select workstation",
"entity_one": "workstation",
"entity_other": "workstations",
"search": "Search by workstation or computer"
},
"person": {
"empty": "Who will use this profile?",
"description": "Select an active person in the company or create a new one. The workstation owner is not assigned automatically.",
"select": "Select person",
"entity_one": "person",
"entity_other": "people",
"search": "Search by person, position or email"
},
"account": {
"empty": "Add a sign-in account",
"description": "The account is optional. Select an existing one, create a new one, or continue without linking an account.",
"select": "Select account",
"entity_one": "account",
"entity_other": "accounts",
"search": "Search by account, system or usage"
}
}

View File

@ -605,18 +605,24 @@
"empty": "Elige el puesto de trabajo del perfil",
"description": "Selecciona un puesto activo de esta compañía con un ordenador principal disponible. Si todavía no existe, completa primero el puesto de trabajo.",
"select": "Seleccionar puesto",
"entity_one": "puesto",
"entity_other": "puestos",
"search": "Buscar por puesto u ordenador"
},
"person": {
"empty": "¿Quién utilizará este perfil?",
"description": "Selecciona una persona activa de la compañía o crea una nueva. El responsable del puesto no se asigna automáticamente.",
"select": "Seleccionar persona",
"entity_one": "persona",
"entity_other": "personas",
"search": "Buscar por persona, puesto o email"
},
"account": {
"empty": "Añade una cuenta de inicio de sesión",
"description": "La cuenta es opcional. Selecciona una existente, crea una nueva o continúa sin vincular ninguna.",
"select": "Seleccionar cuenta",
"entity_one": "cuenta",
"entity_other": "cuentas",
"search": "Buscar por cuenta, sistema o uso"
}
}

View File

@ -56,6 +56,7 @@ export function AssetRelatedInformation({
columns={[
{
id: "name",
hideable: false,
label: t("profiles.name"),
value: (profile) => profile.values.name,
render: (profile) => (

View File

@ -46,6 +46,7 @@ export function DashboardDetailsDialog(props: DashboardDetailsDialogProps) {
columns={[
{
id: "name",
hideable: false,
label: t("name"),
value: (item) => item.name,
render: (item) => (

View File

@ -32,6 +32,32 @@ const company = {
assignments: [],
};
test("las entidades de los selectores se pluralizan en es/en sin reutilizar el verbo de acción", async () => {
const i18n = createInstance();
await i18n.init({
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
});
for (const [language, kind, singular, plural] of [
["es", "workstation", "puesto", "puestos"],
["es", "person", "persona", "personas"],
["es", "account", "cuenta", "cuentas"],
["en", "workstation", "workstation", "workstations"],
["en", "person", "person", "people"],
["en", "account", "account", "accounts"],
] satisfies [string, string, string, string][]) {
await i18n.changeLanguage(language);
assert.equal(
i18n.t(`profiles.selection.${kind}.entity`, { ns: "companies", count: 1 }),
singular
);
assert.equal(
i18n.t(`profiles.selection.${kind}.entity`, { ns: "companies", count: 2 }),
plural
);
}
});
async function render(language: string, node: ReactNode, seed?: (client: QueryClient) => void) {
const i18n = createInstance();

View File

@ -48,6 +48,7 @@ export function WorkProfileSelectionField({
change: t("profiles.selection.change"),
selection: t("profiles.selection.column"),
name: t(`profiles.${kind}`),
entity: (count) => t(`profiles.selection.${kind}.entity`, { count }),
description: t("profiles.selection.details"),
confirm: t("profiles.selection.confirm"),
cancel: t("cancel"),

View File

@ -174,6 +174,9 @@
"SMB_FIELD_REQUIRED": "The template must retain its UNC path field."
},
"fieldPicker": {
"entity_one": "field",
"entity_other": "fields",
"helpLabel": "Section and help",
"destination": "Destination section: {{name}}",
"limit": "You have reached the template field limit. Deselect fields from this selection or remove fields from the template to add others.",
"open": "Select fields",

View File

@ -174,6 +174,9 @@
"SMB_FIELD_REQUIRED": "La plantilla debe conservar el campo de ruta UNC."
},
"fieldPicker": {
"entity_one": "campo",
"entity_other": "campos",
"helpLabel": "Sección y ayuda",
"destination": "Sección de destino: {{name}}",
"limit": "Has alcanzado el límite de campos de la plantilla. Desmarca campos de esta selección o retira alguno de la plantilla para añadir otros.",
"open": "Seleccionar campos",

View File

@ -25,6 +25,7 @@ export function CatalogFieldPickerDialog(props: {
title: t("fieldPicker.title"),
cancel: t("cancel"),
help: t("fieldPicker.help"),
helpLabel: t("fieldPicker.helpLabel"),
destination: t("fieldPicker.destination", {
name: props.sectionName ?? t("sections.ungrouped"),
}),

View File

@ -36,7 +36,7 @@ export function CatalogFieldPickerList({
select: t("fieldPicker.select"),
selectField: (name) => t("fieldPicker.selectField", { name }),
empty: t("fieldPicker.empty"),
fields: t("fields"),
fields: (count) => t("fieldPicker.entity", { count }),
search: t("search"),
}}
locale={i18n.language}

View File

@ -60,6 +60,7 @@ export function EntityListPagination({
onPage,
onPageSize,
id,
presentation = "full",
}: {
locale: string;
page: number;
@ -68,13 +69,22 @@ export function EntityListPagination({
onPage: (page: number) => void;
onPageSize: (size: number) => void;
id: string;
presentation?: "full" | "picker";
}) {
const labels = entityListLabels(locale);
return (
<nav aria-label={labels.page} className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground" htmlFor={`${id}-size`}>
<nav
aria-label={labels.page}
className={`flex items-center gap-2 ${presentation === "picker" ? "w-full @min-[32rem]/entity-table:w-auto" : "flex-wrap"}`}
>
<div
className={`flex items-center gap-2 ${presentation === "picker" ? "w-16 shrink-0" : ""}`}
>
<label
className={presentation === "picker" ? "sr-only" : "text-xs text-muted-foreground"}
htmlFor={`${id}-size`}
>
{labels.pageSize}
</label>
<SelectWithAction
@ -102,18 +112,25 @@ export function EntityListPagination({
>
‹
</Button>
{entityListPages(page, pages).map((index) => (
<Button
aria-current={index === page ? "page" : undefined}
aria-label={`${labels.page} ${index + 1}`}
key={index}
onClick={() => onPage(index)}
size="sm"
variant={index === page ? "secondary" : "ghost"}
>
{index + 1}
</Button>
))}
{presentation === "picker" ? (
<span aria-current="page" className="min-w-0 flex-1 text-center text-sm whitespace-nowrap">
<span className="sr-only">{labels.page} </span>
{page + 1} / {pages}
</span>
) : (
entityListPages(page, pages).map((index) => (
<Button
aria-current={index === page ? "page" : undefined}
aria-label={`${labels.page} ${index + 1}`}
key={index}
onClick={() => onPage(index)}
size="sm"
variant={index === page ? "secondary" : "ghost"}
>
{index + 1}
</Button>
))
)}
<Button
aria-label={labels.next}
disabled={page + 1 >= pages}

View File

@ -83,7 +83,7 @@ export function EntityTable<T extends { id: string }>({
items: readonly T[];
columns: readonly EntityColumn<T>[];
title: string;
entityLabel?: string;
entityLabel?: string | ((count: number) => string);
locale?: string;
search?: string;
onSearchChange?: (value: string) => void;
@ -116,7 +116,13 @@ export function EntityTable<T extends { id: string }>({
loading,
unavailable,
labels.loading,
labels.range(range.from, range.to, total, entityLabel.toLocaleLowerCase(locale))
labels.range(
range.from,
range.to,
total,
entityTableLabel(entityLabel, total, locale),
layout === "fill" ? "compact" : "full"
)
);
const message = entityTableMessage(
loading,
@ -132,14 +138,32 @@ export function EntityTable<T extends { id: string }>({
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
};
const configuration = (
<EntityTableConfiguration
locale={locale}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
presentation={layout === "fill" ? "picker" : "full"}
table={table}
/>
);
return (
<section
aria-busy={loading}
aria-label={title}
className={`min-w-0 rounded-xl border bg-card text-card-foreground ${layout === "fill" ? "flex min-h-96 flex-1 flex-col overflow-hidden sm:min-h-80 [&>[data-slot=table-container]]:min-h-0 [&>[data-slot=table-container]]:flex-1 [&>[data-slot=table-container]]:overflow-auto [&>[data-slot=table-container]]:[scrollbar-gutter:stable] [&_[data-slot=table-header]]:sticky [&_[data-slot=table-header]]:top-0 [&_[data-slot=table-header]]:z-20 [&_[data-slot=table-header]]:bg-background" : ""}`}
className={`min-w-0 rounded-xl border bg-card text-card-foreground ${layout === "fill" ? "@container/entity-table flex min-h-72 flex-1 flex-col overflow-hidden [&>[data-slot=table-container]]:min-h-0 [&>[data-slot=table-container]]:flex-1 [&>[data-slot=table-container]]:overflow-auto [&>[data-slot=table-container]]:overscroll-contain [&>[data-slot=table-container]]:[scrollbar-gutter:stable] [&_[data-slot=table-header]]:sticky [&_[data-slot=table-header]]:top-0 [&_[data-slot=table-header]]:z-20 [&_[data-slot=table-header]]:bg-background" : ""}`}
data-slot="entity-table"
>
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3 border-b p-4">
<div className="w-full sm:max-w-sm">
<div
className={`flex shrink-0 flex-wrap justify-between border-b ${layout === "fill" ? "items-center gap-2 p-2" : "items-start gap-3 p-4"}`}
>
<div
className={
layout === "fill"
? "min-w-0 flex-1 @min-[32rem]/entity-table:max-w-sm"
: "w-full sm:max-w-sm"
}
>
<label className="sr-only" htmlFor={`${id}-search`}>
{searchPlaceholder ?? labels.search}
</label>
@ -151,6 +175,7 @@ export function EntityTable<T extends { id: string }>({
value={currentSearch}
/>
</div>
{layout === "fill" && configuration}
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 [&>fieldset]:min-w-40">
{filters}
{(currentSearch || filtersApplied) && (
@ -160,16 +185,14 @@ export function EntityTable<T extends { id: string }>({
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<p aria-live="polite" className="text-sm text-muted-foreground">
{info}
</p>
<EntityTableConfiguration
locale={locale}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
table={table}
/>
</div>
{layout !== "fill" && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<p aria-live="polite" className="text-sm text-muted-foreground">
{info}
</p>
{configuration}
</div>
)}
<EntityTableContent
columns={columns}
@ -183,8 +206,15 @@ export function EntityTable<T extends { id: string }>({
unavailable={unavailable}
/>
<footer className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t px-4 py-3">
<p className="text-sm text-muted-foreground">{info}</p>
<footer
className={`flex shrink-0 flex-wrap items-center justify-between border-t ${layout === "fill" ? "gap-2 px-2 py-2" : "gap-3 px-4 py-3"}`}
>
<p
aria-live={layout === "fill" ? "polite" : undefined}
className="text-sm text-muted-foreground"
>
{info}
</p>
<EntityListPagination
id={id}
locale={locale}
@ -193,6 +223,7 @@ export function EntityTable<T extends { id: string }>({
page={range.page}
pageSize={pagination.pageSize}
pages={range.pages}
presentation={layout === "fill" ? "picker" : "full"}
/>
</footer>
</section>
@ -212,6 +243,14 @@ function entityTableInfo(
return unavailable ? "—" : info;
}
function entityTableLabel(
label: string | ((count: number) => string),
total: number,
locale: string
) {
return (typeof label === "function" ? label(total) : label).toLocaleLowerCase(locale);
}
function entityTableMessage(
loading: boolean,
unavailable: boolean,

View File

@ -11,10 +11,12 @@ export function EntityTableConfiguration<T>({
table,
locale,
onSort,
presentation = "full",
}: {
table: TableModel<T>;
locale: string;
onSort: () => void;
presentation?: "full" | "picker";
}) {
const id = useId();
const labels = entityListLabels(locale);
@ -33,31 +35,37 @@ export function EntityTableConfiguration<T>({
<Button onClick={() => setColumnsOpen(true)} size="sm" variant="outline">
{labels.columns}
</Button>
<SelectWithAction
id={`${id}-sort`}
label={labels.sort}
onValueChange={(value) => {
const [column, direction] = value.split(":");
<div
className={
presentation === "picker" ? "hidden @min-[32rem]/entity-table:block" : "contents"
}
>
<SelectWithAction
id={`${id}-sort`}
label={labels.sort}
onValueChange={(value) => {
const [column, direction] = value.split(":");
if (column && column !== "initial") {
setSorting([{ id: column, desc: direction === "desc" }]);
} else {
table.resetSorting();
}
onSort();
}}
options={[
{ value: "initial", label: labels.initial },
...columns
.filter((column) => column.value)
.flatMap((column) => [
{ value: `${column.id}:asc`, label: `${column.label} · ${labels.ascending}` },
{ value: `${column.id}:desc`, label: `${column.label} · ${labels.descending}` },
]),
]}
placeholder={labels.sort}
value={sorting[0] ? `${sorting[0].id}:${sorting[0].desc ? "desc" : "asc"}` : "initial"}
/>
if (column && column !== "initial") {
setSorting([{ id: column, desc: direction === "desc" }]);
} else {
table.resetSorting();
}
onSort();
}}
options={[
{ value: "initial", label: labels.initial },
...columns
.filter((column) => column.value)
.flatMap((column) => [
{ value: `${column.id}:asc`, label: `${column.label} · ${labels.ascending}` },
{ value: `${column.id}:desc`, label: `${column.label} · ${labels.descending}` },
]),
]}
placeholder={labels.sort}
value={sorting[0] ? `${sorting[0].id}:${sorting[0].desc ? "desc" : "asc"}` : "initial"}
/>
</div>
</div>{" "}
<EntityTableColumnsDialog
locale={locale}

View File

@ -5,6 +5,7 @@ import { useFieldPickerController } from "../hooks/use-field-picker-controller";
import type { FieldPickerSelection } from "./field-picker-list";
import { FormActions, FormBody, FormDialog } from "./form-dialog";
import { PickerHelp } from "./picker-help";
/** Selección provisional y alta opcional compartidas, independientes de catálogos y endpoints. */
export function FieldPickerDialog(props: {
@ -16,6 +17,7 @@ export function FieldPickerDialog(props: {
title: string;
cancel: string;
help: string;
helpLabel: string;
destination: string;
selected: (count: number) => string;
limit: string;
@ -56,8 +58,6 @@ export function FieldPickerDialog(props: {
wide
>
<FormBody>
<p className="text-sm text-muted-foreground">{props.labels.help}</p>
<p className="text-sm font-medium">{props.labels.destination}</p>
{props.renderList({ disabled: c.disabled, onToggle: c.toggle, selectedIds: c.selectedIds })}
<p aria-live="polite" className="text-sm text-muted-foreground">
{props.labels.selected(c.selectedIds.length)}
@ -73,6 +73,11 @@ export function FieldPickerDialog(props: {
</p>
)}
</FormBody>
<PickerHelp
description={props.labels.help}
label={props.labels.helpLabel}
title={props.labels.destination}
/>
<FormActions
leadingActions={
props.quickCreate && (

View File

@ -26,7 +26,7 @@ export function FieldPickerList({
select: string;
selectField: (name: string) => string;
empty: string;
fields: string;
fields: (count: number) => string;
search: string;
};
locale: string;

View File

@ -69,7 +69,7 @@ export function FormDialog({
<DialogTitle className="text-lg font-bold">{title}</DialogTitle>
</DialogHeader>
<div
className="@container flex min-h-0 flex-1 flex-col overflow-y-auto px-6 pt-5 pb-5 [&:has([data-slot=form-body])]:overflow-hidden [&:has([data-slot=form-body])]:pb-0"
className={`@container flex min-h-0 flex-1 flex-col overflow-y-auto px-6 pb-5 ${layout === "picker" ? "pt-3" : "pt-5"} [&:has([data-slot=form-body])]:overflow-hidden [&:has([data-slot=form-body])]:pb-0`}
data-slot="form-dialog-content"
>
<FormCancelContext.Provider value={formActions ? cancelLabel : undefined}>
@ -98,10 +98,17 @@ export function FormActions({
cancelDisabled?: boolean;
}) {
const cancelLabel = useContext(FormCancelContext);
const layout = useContext(FormLayoutContext);
return (
<DialogFooter className="shrink-0 border-t bg-popover py-4">
{leadingActions && <div className="flex sm:mr-auto">{leadingActions}</div>}
<DialogFooter
className={`shrink-0 border-t bg-popover ${layout === "picker" ? "flex-row flex-wrap gap-2 py-2 [&>button]:flex-1 sm:[&>button]:flex-none" : "py-4"}`}
>
{leadingActions && (
<div className={`flex sm:mr-auto ${layout === "picker" ? "w-full sm:w-auto" : ""}`}>
{leadingActions}
</div>
)}
{cancelLabel && (
<DialogClose render={<Button disabled={cancelDisabled} type="button" variant="outline" />}>
{cancelLabel}
@ -125,7 +132,7 @@ export function FormBody({
return (
<div
className={`@container min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-5 ${fills ? "flex flex-col [&>*]:shrink-0" : "space-y-5"} ${layout === "picker" ? "gap-3" : fills ? "gap-5" : ""} ${layout === "content" ? "" : "[scrollbar-gutter:stable]"}`}
className={`@container min-h-0 flex-1 px-1 ${layout === "picker" ? "flex flex-col gap-2 overflow-y-auto overscroll-contain pt-1 pb-2 [&>p]:shrink-0" : `overflow-y-auto pt-1 pb-5 ${fills ? "flex flex-col gap-5 [&>*]:shrink-0" : "space-y-5"}`} ${layout === "content" ? "" : "[scrollbar-gutter:stable]"}`}
data-slot="form-body"
>
{children}

View File

@ -30,6 +30,7 @@ export function OptionPickerDialog({
labels: {
selection: string;
name: string;
entity: (count: number) => string;
description: string;
confirm: string;
cancel: string;

View File

@ -17,7 +17,13 @@ export function OptionPickerList({
options: readonly PickerOption[];
value: string;
onChange: (id: string) => void;
labels: { selection: string; name: string; description: string; search: string };
labels: {
selection: string;
name: string;
entity: (count: number) => string;
description: string;
search: string;
};
locale: string;
loading?: boolean;
}) {
@ -30,7 +36,7 @@ export function OptionPickerList({
return (
<RadioGroup
aria-label={title}
className="flex min-h-96 flex-1 flex-col"
className="flex min-h-0 flex-1 flex-col"
onValueChange={(id) => {
if (typeof id === "string") {
choose(id);
@ -55,6 +61,7 @@ export function OptionPickerList({
{
id: "name",
label: labels.name,
hideable: false,
value: (option) => option.label,
render: (option) => <span className="font-medium">{option.label}</span>,
},
@ -65,6 +72,7 @@ export function OptionPickerList({
render: (option) => option.description || "—",
},
]}
entityLabel={labels.entity}
items={options}
layout="fill"
loading={loading}

View File

@ -34,6 +34,7 @@ export function OptionSelectionField({
change: string;
selection: string;
name: string;
entity: (count: number) => string;
description: string;
confirm: string;
cancel: string;

View File

@ -0,0 +1,40 @@
import { Button } from "@repo/shadcn-ui/components/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverTitle,
PopoverTrigger,
} from "@repo/shadcn-ui/components/popover";
import { InfoIcon } from "../lib/icons";
/** La ayuda completa se consulta junto al pie sin competir con la superficie de selección. */
export function PickerHelp({
label,
title,
description,
}: {
label: string;
title: string;
description: string;
}) {
return (
<div className="shrink-0 pb-2">
<Popover>
<PopoverTrigger render={<Button size="sm" type="button" variant="ghost" />}>
<InfoIcon aria-hidden="true" />
{label}
</PopoverTrigger>
<PopoverContent
align="start"
className="max-h-[min(60dvh,var(--available-height))] max-w-[calc(100vw-2rem)] overflow-y-auto"
side="top"
>
<PopoverTitle className="text-sm font-semibold">{title}</PopoverTitle>
<PopoverDescription>{description}</PopoverDescription>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -41,6 +41,7 @@ test("las columnas elegidas se restauran al volver a crear la tabla, incluidas l
optional: false,
name: false,
actions: false,
unknownColumn: true,
});
const render = (includeLater: boolean) =>
@ -80,6 +81,7 @@ test("las columnas elegidas se restauran al volver a crear la tabla, incluidas l
assert.ok(restored.includes("Columna asíncrona"));
assert.ok(restored.includes("Equipo"));
assert.ok(restored.includes("Editar"));
assert.equal(restored.includes("unknownColumn"), false);
assert.equal(restored.includes("Dato oculto"), false);
assert.equal(readColumnVisibility("assets").later, true);
assert.equal(storage.has("entity-page-size"), false);

View File

@ -8,8 +8,14 @@ export function entityListLabels(locale = "es") {
return {
...labels,
range: (from: number, to: number, total: number, entity: string) =>
labels.range
range: (
from: number,
to: number,
total: number,
entity: string,
presentation: "full" | "compact" = "full"
) =>
(presentation === "compact" ? labels.pickerRange : labels.range)
.replace("{{from}}", String(from))
.replace("{{to}}", String(to))
.replace("{{total}}", String(total))

View File

@ -19,6 +19,7 @@
"empty": "No records.",
"noResults": "No matching results.",
"description": "View and manage",
"pickerRange": "{{from}}–{{to}} of {{total}} {{entity}}",
"range": "Showing {{from}}-{{to}} of {{total}} {{entity}}",
"close": "Close"
}

View File

@ -19,6 +19,7 @@
"empty": "No hay registros.",
"noResults": "No hay resultados para estos criterios.",
"description": "Consulta y gestiona",
"pickerRange": "{{from}}–{{to}} de {{total}} {{entity}}",
"range": "Mostrando {{from}}-{{to}} de {{total}} {{entity}}",
"close": "Cerrar"
}

View File

@ -0,0 +1,183 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { createRoot } from "react-dom/client";
import { CursorPagination } from "./components/cursor-pagination";
import { FieldPickerDialog } from "./components/field-picker-dialog";
import { FieldPickerList } from "./components/field-picker-list";
import { OptionPickerDialog } from "./components/option-picker-dialog";
import { RemoteOptionPicker } from "./components/remote-option-picker";
/** Fixture exclusiva de navegador: utiliza componentes reales sin rutas ni persistencia. */
function PickerBrowserFixture({
count,
locale,
longNames,
kind,
}: {
count: number;
locale: string;
longNames: boolean;
kind: "fields" | "options" | "remote";
}) {
const [open, setOpen] = useState(false);
const [applied, setApplied] = useState<readonly string[]>([]);
const [remotePage, setRemotePage] = useState(0);
const [search, setSearch] = useState("");
const labels = fixtureLabels(locale);
const title = labels.title;
const items = Array.from({ length: count }, (_, index) => ({
id: `option-${index}`,
name: `${labels.name} ${index + 1}${longNames ? " · descripción técnica extensa".repeat(8) : ""}`,
description: "",
type: labels.type,
unit: "—",
}));
return (
<>
<Button onClick={() => setOpen(true)} type="button">
{title}
</Button>
<output aria-label="Resultado">{applied.join(",") || "—"}</output>
{open &&
(kind === "remote" ? (
<RemoteOptionPicker
error={undefined}
items={items
.slice(remotePage * 10, remotePage * 10 + 10)
.map((item) => ({ id: item.id, label: item.name, description: item.description }))}
labels={{ ...labels, empty: labels.empty, loading: "…", retry: "Reintentar" }}
onClose={() => setOpen(false)}
onRetry={() => Promise.resolve()}
onSearch={setSearch}
onSelect={(item) => setApplied([item.id])}
pagination={
<CursorPagination
canNext={(remotePage + 1) * 10 < count}
canPrevious={remotePage > 0}
nextLabel="Siguiente"
onNext={() => setRemotePage(remotePage + 1)}
onPrevious={() => setRemotePage(remotePage - 1)}
onRestart={() => setRemotePage(0)}
pending={false}
previousLabel="Anterior"
restartLabel="Primera página"
/>
}
pending={false}
search={search}
title={title}
/>
) : kind === "options" ? (
<OptionPickerDialog
labels={labels}
locale={locale}
onClose={() => setOpen(false)}
onSelect={(id) => setApplied([id])}
options={items.map((item) => ({
id: item.id,
label: item.name,
description: item.description,
}))}
title={title}
value={applied[0] ?? ""}
/>
) : (
<FieldPickerDialog
canAdd={(ids) => !ids.includes("option-9")}
items={items}
labels={labels}
onClose={() => setOpen(false)}
onConfirm={(ids) => {
setApplied(ids);
return true;
}}
renderList={(selection) => (
<FieldPickerList
{...selection}
fields={items}
labels={{
...labels,
title,
type: labels.type,
unit: labels.unit,
select: labels.selection,
selectField: (name) => `${labels.confirm}: ${name}`,
empty: labels.empty,
fields: labels.entity,
}}
locale={locale}
/>
)}
/>
))}
</>
);
}
export function mountPickerFixture(
element: HTMLElement,
options: Parameters<typeof PickerBrowserFixture>[0]
) {
const root = createRoot(element);
root.render(<PickerBrowserFixture {...options} />);
return root;
}
function fixtureLabels(locale: string) {
const text =
locale === "en"
? {
title: "Choose fields",
selection: "Selection",
name: "Field",
plural: "fields",
singular: "field",
description: "Details",
search: "Search",
confirm: "Choose",
cancel: "Cancel",
helpLabel: "Section and help",
destination: "Destination section",
help: "Choose the fields you need. ",
selectedPrefix: "Selected fields",
limit: "Unavailable field",
invalid: "Check the selection",
create: "New field",
type: "Text",
unit: "Unit",
empty: "No fields",
}
: {
title: "Seleccionar campos",
selection: "Selección",
name: "Campo",
plural: "campos",
singular: "campo",
description: "Detalles",
search: "Buscar",
confirm: "Seleccionar",
cancel: "Cancelar",
helpLabel: "Sección y ayuda",
destination: "Sección de destino",
help: "Selecciona los campos necesarios. ",
selectedPrefix: "Campos seleccionados",
limit: "Campo no disponible",
invalid: "Revisa la selección",
create: "Nuevo campo",
type: "Texto",
unit: "Unidad",
empty: "No hay campos",
};
return {
...text,
help: text.help.repeat(40),
selected: (count: number) => `${text.selectedPrefix}: ${count}`,
entity: (count: number) => (count === 1 ? text.singular : text.plural),
};
}

View File

@ -48,6 +48,7 @@ const labels = {
change: "Cambiar persona",
selection: "Selección",
name: "Persona",
entity: (count: number) => (count === 1 ? "persona" : "personas"),
description: "Detalles",
confirm: "Confirmar",
cancel: "Cancelar",
@ -169,6 +170,35 @@ test("PickerList: estados de carga y vacío no presentan opciones falsas", () =>
assert.equal(loading.includes('role="radio"'), false);
});
test("el rango del selector usa la entidad y su plural, nunca el título de la acción", () => {
for (const [locale, singular, plural, title] of [
["es", "persona", "personas", "Seleccionar persona"],
["en", "person", "people", "Choose a person"],
] satisfies [string, string, string, string][]) {
for (const count of [0, 1, 2]) {
const html = renderToStaticMarkup(
<OptionPickerList
labels={{
...labels,
entity: (total) => (total === 1 ? singular : plural),
}}
locale={locale}
onChange={() => undefined}
options={options.slice(0, count)}
title={title}
value=""
/>
);
const range = html.match(/<p aria-live="polite"[^>]*>(.*?)<\/p>/)?.[1];
assert.ok(range);
assert.ok(range.includes(count === 1 ? singular : plural));
assert.equal(range.includes("Choose"), false);
assert.equal(range.includes("Seleccionar"), false);
}
}
});
test("acciones multipaso: requisitos bloquean avance y guardado en cada paso", () => {
for (const step of [1, 2, 3, 4, undefined]) {
for (const canContinue of [false, true]) {

265
scripts/picker-ui.test.py Normal file
View File

@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Regresión de selectores mediante el MCP/Chromium del preparador sintético existente.
METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
python3 scripts/picker-ui.test.py
No inicia servicios ni escribe entidades. La fixture React solo se importa por Vite en tests.
"""
import contextlib
import json
import os
from pathlib import Path
import runpy
import tomllib
import unittest
from urllib.parse import quote, urlparse
ROOT = Path(__file__).resolve().parent.parent
AUDIT = runpy.run_path(str(ROOT / "scripts/visual-audit.py"))
SIZES = [(320, 640), (390, 844), (768, 1024), (1024, 768), (1366, 768), (1440, 900)]
class PickerUiTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
value = os.environ.get("METIS_PLAYWRIGHT_STORAGE_STATE")
if not value:
raise unittest.SkipTest("Requiere sesión sintética activa de scripts/visual-audit.py.")
state = (ROOT / value).resolve()
if not state.is_relative_to(ROOT / ".artifacts/playwright") or state.stat().st_mode & 0o077:
raise RuntimeError("La sesión debe ser privada y pertenecer al directorio de auditoría.")
report = json.loads((state.parent / "report.json").read_text())
if urlparse(report['origin']).hostname != 'localhost' or report.get('ordinaryUser') is not True:
raise RuntimeError("Se requiere una sesión sintética local ordinaria.")
cls.origin = report['origin']
cls.company = '/companies/' + report['companyId']
cls.asset_id = report['assetId']
cls.output = ROOT / '.artifacts/playwright/l02-1'
cls.output.mkdir(parents=True, exist_ok=True)
cls.results = []
cls.measurements = cls.output / ('measurements-' + str(os.getpid()) + '.json')
cls.stack = contextlib.ExitStack()
cls.addClassCleanup(cls.stack.close)
config = tomllib.loads((ROOT / '.codex/config.toml').read_text())['mcp_servers']['playwright']
cls.client = cls.stack.enter_context(AUDIT['browser'](config, state))
status=cls.code('return (await page.request.get('+json.dumps(cls.origin+'/api/companies')+')).status();')
if status != 200:
raise RuntimeError('La sesión sintética no está activa; preparar otra según docs/playwright-mcp.md.')
cls.code("page.blockedWrites=[];await page.route('**/api/**',r=>{if(['GET','HEAD','OPTIONS'].includes(r.request().method()))return r.continue();page.blockedWrites.push(r.request().method());return r.abort()});return true;")
@classmethod
def tool(cls, name, args):
return AUDIT['tool'](cls.client, name, args)
@classmethod
def code(cls, body):
return AUDIT['result_json'](cls.tool('browser_run_code_unsafe', {'code': 'async(page)=>{' + body + '}'}))
@classmethod
def tearDownClass(cls):
(cls.output / 'console.json').write_text(json.dumps(cls.tool('browser_console_messages', {'level': 'error'}), ensure_ascii=False, indent=2)+'\n')
(cls.output / 'blocked-writes.json').write_text(json.dumps(cls.code('return page.blockedWrites;'))+'\n')
def settle(self):
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))});return true;")
def resize(self, w, h):
self.tool('browser_resize', {'width': w, 'height': h})
self.settle()
def navigate(self, path):
self.code("await page.evaluate(()=>window.pickerTestRoot?.unmount());return true;")
self.tool('browser_navigate', {'url': self.origin+path})
self.code("await page.locator('[data-slot=workspace]').waitFor();return true;")
self.settle()
def record(self, name, value):
self.results.append({'scenario': name, 'value': value})
self.measurements.write_text(json.dumps(self.results, ensure_ascii=False, indent=2)+'\n')
def capture(self, name):
AUDIT['capture'](self.client, self.output, name)
result = self.tool('browser_snapshot', {})
(self.output / (name+'.txt')).write_text('\n'.join(v['text'] for v in result['content'] if v.get('type') == 'text'))
def fixture(self, count=12, locale='es', long_names=False, kind='fields'):
self.navigate('/companies')
module = '/@fs/' + quote(str(ROOT / 'packages/rdx-ui/src/picker-browser-fixture.tsx'), safe='/')
options = {'count': count, 'locale': locale, 'longNames': long_names, 'kind': kind}
self.code('const target=await page.evaluateHandle(()=>{const d=document.createElement("div");document.body.append(d);return d});await page.evaluate(async({target,options,module})=>{const {mountPickerFixture}=await import(module);window.pickerTestRoot=mountPickerFixture(target,options)},{target,options:'+json.dumps(options)+',module:'+json.dumps(module)+'});return true;')
self.code("await page.getByRole('button',{name:"+json.dumps('Choose fields' if locale == 'en' else 'Seleccionar campos')+",exact:true}).click();await page.getByRole('dialog').locator('input[data-quick-search]').waitFor();return true;")
self.settle()
def geometry(self):
return self.code("""return await page.locator('[role=dialog]:has([data-slot=form-body])').last().evaluate(d=>{
let c=d.querySelector('[data-slot=table-container]');for(let n=c.parentElement;n&&n!==d;n=n.parentElement){if(getComputedStyle(n).overflowY==='auto'&&n.clientHeight<c.clientHeight)c=n}const b=c.getBoundingClientRect(),head=d.querySelector('thead').getBoundingClientRect();
const body=d.querySelector('[data-slot=form-body]'),br=body.getBoundingClientRect(),f=d.querySelector('[data-slot=dialog-footer]').getBoundingClientRect(),dr=d.getBoundingClientRect();
const bottom=Math.min(b.bottom,br.bottom,f.top),top=Math.max(b.top,br.top,head.bottom);
const rows=[...d.querySelectorAll('tbody tr')].filter(e=>e.querySelector('[role=checkbox],[role=radio]'));
return {viewport:[innerWidth,innerHeight],height:dr.height,tableClient:c.clientHeight,tableScroll:c.scrollHeight,bodyClient:body.clientHeight,bodyScroll:body.scrollHeight,full:rows.filter(e=>{const r=e.getBoundingClientRect();return r.top>=top-.5&&r.bottom<=bottom+.5}).length,rows:rows.length,footerVisible:f.top>=dr.top&&f.bottom<=dr.bottom+.5,focusQuick:document.activeElement.hasAttribute('data-quick-search'),overflow:document.documentElement.scrollWidth>innerWidth};
});""")
def test_01_capacity_counts_and_resize(self):
for count in [0, 1, 2, 12]:
with self.subTest(count=count):
self.resize(320, 640)
self.fixture(count=count)
g=self.geometry();self.record('count-'+str(count),g)
self.assertGreater(g['tableClient'], 0)
self.assertTrue(g['footerVisible'])
self.assertTrue(g['focusQuick'])
self.assertFalse(g['overflow'])
if count < 3:self.assertEqual(g['full'],count)
if count>=2:self.assertGreaterEqual(g['full'],2)
self.code("await page.keyboard.press('Escape');return true;")
for kind in ['fields','options']:
self.fixture(kind=kind)
for w,h in SIZES:
self.resize(w,h);g=self.geometry();self.record(f'{kind}-{w}',g)
self.assertGreater(g['tableClient'],0)
self.assertGreaterEqual(g['full'],2)
self.assertTrue(g['footerVisible'])
self.assertFalse(g['overflow'])
if w in [320,1440]:self.capture(f'after-fixture-{kind}-{w}')
self.code("await page.keyboard.press('Escape');return true;")
def test_02_long_help_and_short_window(self):
self.resize(320,640);self.fixture(long_names=True)
g=self.geometry();self.record('long-names',g);self.assertGreater(g['tableClient'],0)
self.code("await page.getByRole('dialog').locator('[data-slot=table-container]').evaluate(e=>e.scrollTop=e.scrollHeight);return true;")
self.settle();self.capture('after-long-names-scrolled')
self.code("await page.getByRole('button',{name:'Sección y ayuda',exact:true}).click();await page.locator('[data-slot=popover-content]').waitFor();return true;")
self.settle()
help_metrics=self.code("return await page.locator('[data-slot=popover-content]').evaluate(e=>({client:e.clientHeight,scroll:e.scrollHeight,top:e.getBoundingClientRect().top,bottom:e.getBoundingClientRect().bottom,text:e.textContent}));")
self.record('long-help',help_metrics);self.assertGreater(help_metrics['client'],0)
self.assertGreater(help_metrics['scroll'],help_metrics['client'])
self.assertIn('Sección de destino',help_metrics['text'])
self.code("await page.keyboard.press('PageDown');await page.waitForFunction(()=>document.querySelector('[data-slot=popover-content]').scrollTop>0);return true;")
self.record('help-keyboard-scroll',self.code("return await page.locator('[data-slot=popover-content]').evaluate(e=>({scroll:e.scrollTop,focused:e.contains(document.activeElement)}));"))
self.assertEqual(self.geometry()['height'],g['height'])
self.capture('after-help-320')
self.code("await page.keyboard.press('Escape');await page.locator('[data-slot=popover-content]').waitFor({state:'hidden'});return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
self.resize(320,480);self.record('short-window',self.geometry());self.assertGreater(self.geometry()['tableClient'],0)
self.capture('after-short-window')
self.code("await page.keyboard.press('Escape');return true;")
def test_03_identity_draft_keyboard_and_paging(self):
self.resize(390,844);self.fixture(kind='options')
self.code("await page.getByRole('dialog').getByRole('button',{name:'Columnas',exact:true}).click();return true;")
states=self.code("return await page.getByRole('dialog').last().getByRole('checkbox').evaluateAll(es=>es.map(e=>({label:e.closest('label').textContent,disabled:e.getAttribute('aria-disabled')==='true'||e.hasAttribute('disabled')})));")
self.record('protected-columns',states)
self.assertTrue(next(x for x in states if x['label']=='Campo')['disabled'])
self.assertTrue(next(x for x in states if x['label']=='Selección')['disabled'])
self.code("await page.getByRole('dialog').last().getByRole('checkbox',{name:'Detalles',exact:true}).uncheck();await page.keyboard.press('Escape');await page.getByRole('dialog').getByRole('radio').first().focus();await page.keyboard.press('Space');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('radio').first().getAttribute('aria-checked');"),'true')
self.assertEqual(self.code("return await page.locator('output').textContent();"),'—')
self.capture('after-option-identity')
self.code("await page.getByRole('button',{name:'Cancelar',exact:true}).click();return true;")
self.assertTrue(self.code("return await page.getByRole('button',{name:'Seleccionar campos',exact:true}).evaluate(e=>e===document.activeElement);"))
self.code("await page.getByRole('button',{name:'Seleccionar campos',exact:true}).click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('radio').first().getAttribute('aria-checked');"),'false')
self.code("await page.getByRole('dialog').getByRole('radio').first().check();await page.getByRole('dialog').getByRole('button',{name:'Página siguiente',exact:true}).click();return true;")
self.assertEqual(self.geometry()['rows'],2)
self.code("await page.getByRole('dialog').getByRole('button',{name:'Seleccionar',exact:true}).click();return true;")
self.assertEqual(self.code("return await page.locator('output').textContent();"),'option-0')
def test_04_catalogs_actual_nested_and_empty(self):
for module in ['assets','resources']:
self.resize(320,640);self.navigate('/catalogs/'+module)
self.code("await page.getByRole('button',{name:'Nueva plantilla',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:/^Añadir campo/}).click();return true;")
self.settle();initial=self.geometry();self.record('catalog-'+module,initial)
self.assertGreater(initial['tableClient'],0);self.assertTrue(initial['focusQuick'])
self.capture('after-catalog-'+module+'-320')
self.code("await page.getByRole('dialog').last().getByRole('button',{name:'Nuevo campo',exact:true}).click();return true;")
self.settle();self.capture('after-quick-create-'+module);self.assertEqual(self.code("return await page.locator('[role=dialog][data-slot=dialog-content]').count();"),3)
self.code("await page.keyboard.press('Escape');await page.locator('[role=dialog] input[data-quick-search]').waitFor({state:'visible'});return true;")
self.settle();self.capture('after-quick-return-'+module)
self.assertEqual(self.code("return await page.locator('[role=dialog][data-slot=dialog-content]').count();"),2)
self.assertTrue(self.code("return await page.getByRole('button',{name:'Nuevo campo',exact:true}).evaluate(e=>e===document.activeElement);"))
self.code("await page.getByRole('dialog').last().locator('input[data-quick-search]').fill('NO-EXISTE-L02');return true;")
self.settle();self.assertEqual(self.geometry()['height'],initial['height'])
self.assertEqual(self.geometry()['rows'],0)
self.code("await page.keyboard.press('Escape');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
self.assertTrue(self.code("return await page.evaluate(()=>document.activeElement.tagName==='BUTTON');"))
def test_05_english(self):
self.resize(320,640);self.fixture(locale='en',kind='options',count=1)
text=self.code("return await page.getByRole('dialog').locator('p[aria-live]').innerText();")
self.assertEqual(text,'1–1 of 1 field')
self.assertNotIn('Choose',text)
self.record('english-range',text);self.capture('after-english-320')
def test_06_remote(self):
self.resize(320,640);self.fixture(kind='remote')
self.assertGreater(self.geometry()['tableClient'],0)
self.code("await page.getByRole('dialog').getByRole('radio').first().check();await page.getByRole('button',{name:'Siguiente',exact:true}).click();return true;")
self.assertEqual(self.geometry()['rows'],2)
self.capture('after-remote-320');self.record('remote',self.geometry())
self.code("await page.getByRole('button',{name:'Cancelar',exact:true}).click();return true;")
self.assertEqual(self.code('return await page.locator(\'output[aria-label="Resultado"]\').textContent();'),'—')
def test_07_actual_assets_profile_and_domain(self):
self.resize(1440,900)
self.code("""const companyId="""+json.dumps(self.company.split('/')[-1])+""";
const siteId='00000000-0000-7000-8000-000000000201';
await page.route('**/api/companies/'+companyId,async r=>{const response=await r.fetch();const data=await response.json();data.company.sites=[{id:siteId,companyId,name:'L02 Sede sintética',active:true,address:null,phone:null}];await r.fulfill({response,json:data})});
await page.route('**/api/assets/'+companyId,async r=>{const response=await r.fetch();const data=await response.json();const base=data.assets[0];data.assets=Array.from({length:12},(_,i)=>{const a=structuredClone(base);if(i)a.id='00000000-0000-7000-8000-'+String(300+i).padStart(12,'0');a.values.siteId=siteId;a.values.name='L02 Equipo '+String(i+1).padStart(2,'0');a.assigned=i===0;a.displayStatus=i===0?'in_use':'available';a.history=[{...a.history[0],id:'00000000-0000-7000-8000-'+String(500+i).padStart(12,'0'),values:structuredClone(a.values)}];return a});const values={name:'L02 Puesto',active:true,siteId,personId:null,computerAssetId:base.id,assetIds:[base.id]};data.workstations=[{id:'00000000-0000-7000-8000-000000000600',companyId,values,history:[{...base.history[0],id:'00000000-0000-7000-8000-000000000601',values}]}];await r.fulfill({response,json:data})});return true;
""")
self.navigate(self.company+'/workstations')
self.code("await page.getByRole('button',{name:'Nuevo puesto',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Siguiente',exact:true}).click();return true;")
label=self.code("return await page.getByRole('button',{name:'Seleccionar activos',exact:true}).evaluate(e=>({text:e.textContent,override:e.getAttribute('aria-label')}));")
self.assertIsNone(label['override']);self.record('actual-asset-label',label)
self.code("await page.getByRole('button',{name:'Seleccionar activos',exact:true}).click();return true;")
self.settle()
for w,h in SIZES:
self.resize(w,h);g=self.geometry();self.record('actual-assets-'+str(w),g)
if w==320:self.assertGreaterEqual(g['full'],2)
self.assertGreater(g['tableClient'],0);self.assertTrue(g['footerVisible']);self.assertFalse(g['overflow'])
if w==320:self.capture('after-actual-assets-320')
self.code("await page.getByRole('dialog').last().locator('[role=checkbox]:not([aria-disabled=true])').first().check();await page.getByRole('dialog').last().getByRole('button',{name:'Cancelar',exact:true}).click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
self.code("await page.getByRole('button',{name:'Seleccionar activos',exact:true}).click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').last().locator('[role=checkbox][aria-checked=true]').count();"),0)
self.code("await page.getByRole('dialog').last().locator('[role=checkbox]:not([aria-disabled=true])').first().check();await page.getByRole('dialog').last().locator('[data-slot=dialog-footer] button').last().click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
self.code("await page.keyboard.press('Escape');return true;")
self.resize(1440,900);self.navigate(self.company+'/work-profiles')
self.code("await page.getByRole('button',{name:'Nuevo perfil',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Seleccionar puesto',exact:true}).click();return true;")
self.settle();text=self.code("return await page.getByRole('dialog').last().locator('p[aria-live]').innerText();")
self.assertEqual(text,'1–1 de 1 puesto');self.record('actual-workstation-range',text);self.capture('after-actual-workstation')
self.code("await page.keyboard.press('Escape');await page.keyboard.press('Escape');return true;")
self.navigate(self.company+'/assets/'+self.asset_id)
self.code("await page.getByRole('button',{name:'Asociar a dominio',exact:true}).click();await page.getByRole('dialog').locator('button[id$=\"-domain\"]').click();return true;")
self.settle();self.resize(320,640);self.capture('after-actual-domain-320')
self.assertTrue(self.code("return await page.getByRole('dialog').last().locator('input[data-quick-search]').evaluate(e=>e===document.activeElement);"))
self.code("await page.keyboard.press('Escape');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
def test_08_checkboxes_and_effective_text_zoom(self):
self.resize(390,844);self.fixture()
self.code("await page.getByRole('dialog').locator('tbody tr').first().locator('td').nth(1).click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('checkbox').first().getAttribute('aria-checked');"),'true')
self.code("await page.getByRole('dialog').getByRole('checkbox').first().uncheck();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('checkbox').first().getAttribute('aria-checked');"),'false')
self.code("await page.getByRole('dialog').locator('[data-slot=table-container]').evaluate(e=>e.scrollTop=e.scrollHeight);return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('checkbox').nth(9).getAttribute('aria-disabled');"),'true')
self.code("await page.getByRole('dialog').getByRole('button',{name:'Página siguiente',exact:true}).click();return true;")
self.assertEqual(self.geometry()['rows'],2)
self.code("await page.evaluate(()=>document.documentElement.style.fontSize='200%');return true;")
self.settle();g=self.geometry();self.record('text-scale-200',g)
self.assertGreater(g['tableClient'],0);self.assertGreater(g['bodyClient'],0)
self.assertFalse(g['overflow'])
self.code("await page.getByRole('dialog').getByRole('checkbox').first().focus();return true;")
self.settle()
focused=self.code("return await page.getByRole('dialog').getByRole('checkbox').first().evaluate(e=>{const r=e.getBoundingClientRect(),b=e.closest('[data-slot=form-body]').getBoundingClientRect();return {top:r.top,bottom:r.bottom,bodyTop:b.top,bodyBottom:b.bottom}});")
self.record('text-scale-focus',focused)
self.assertGreaterEqual(focused['top'],focused['bodyTop'])
self.assertLessEqual(focused['bottom'],focused['bodyBottom'])
self.capture('after-text-scale-200')
self.code("await page.evaluate(()=>document.documentElement.style.fontSize='');await page.keyboard.press('Escape');return true;")
if __name__=='__main__':
unittest.main(verbosity=2)