feat: Implement responsive navigation architecture V01.2 for tablet and laptop

- Introduced `CompanyDetailBody` to maintain content identity and landmark separation from menu presentation.
- Added `CompanyFixedNavigation` to ensure visibility of identity while allowing navigation without content displacement.
- Created sidebar preference helper functions for reading and saving sidebar collapsed state.
- Defined workspace navigation capacity budgets and modes to manage responsive behavior based on shell width.
- Implemented focus management for workspace navigation to ensure proper accessibility and user experience.
- Developed a custom hook `useWorkspaceCapacity` to observe shell size changes and update navigation capacity accordingly.
- Updated documentation to reflect changes in responsive navigation architecture and testing procedures.
This commit is contained in:
David Arranz 2026-10-21 23:35:56 +02:00
parent 73655f86c0
commit 74b63cb385
28 changed files with 1040 additions and 238 deletions

View File

@ -142,9 +142,13 @@ Implementación representativa: [CompaniesWorkspace](modules/companies/src/web/w
| Panel de inventario `clamp(320px,24vw,400px)` con gap-6 | [AssetInventoryPage](modules/assets/src/web/assets/list/ui/inventory-page.tsx) |
| Detalle compacto hasta 1099 px | [useCompactDetail](packages/rdx-ui/src/hooks/use-compact-detail.ts) |
Estos son umbrales existentes, **no mínimos operativos validados para layouts futuros**. V01.1 implementa la composición móvil por debajo de `shell` (768 px): cabecera compacta, franja de compañía/sección y navegación global/contextual mediante un único Sheet modal abierto a la vez. La búsqueda móvil reutiliza la lógica existente y el salto al contenido conserva foco accesible. En los datos sintéticos verificados, `main` comienza en Y=106 px a 320 y 390 px; la ampliación de texto puede aumentar ese alto. Desde 768 px se conserva la composición anterior. Véase [entrega y validación V01.1](docs/navegacion-movil-v01-1.md).
Estos son umbrales existentes, **no mínimos operativos validados para layouts futuros**. V01.1 implementa la composición móvil por debajo de `shell` (768 px): cabecera compacta, franja de compañía/sección y navegación global/contextual mediante un único Sheet modal abierto a la vez. La búsqueda móvil reutiliza la lógica existente y el salto al contenido conserva foco accesible. En los datos sintéticos verificados, `main` comienza en Y=106 px a 320 y 390 px; la ampliación de texto puede aumentar ese alto. V01.2 extiende el Sheet y la franja a los tamaños sin capacidad para columna fija. Véanse [V01.1](docs/navegacion-movil-v01-1.md) y [navegación responsive V01.2](docs/navegacion-responsive-v01-2.md).
V01 sigue parcialmente pendiente: no se implementan aún colapso automático en portátil/tablet, nuevos presupuestos ni tabla/panel por capacidad. `inventory`, `dashboard` y `useCompactDetail` mantienen sus contratos.
La política de V01.2 mide el contenedor del shell y distingue tres presupuestos centralizados: 920 px para raíl de 72, 1160 para raíl y secundaria fija de 240, y 1660 para expansión global de 228 según preferencia. Por debajo de cada capacidad, la presentación correspondiente pasa a Sheet; la secundaria puede ocultarse temporalmente y fijarse de nuevo si cabe. La preferencia `metis-desk:sidebar-collapsed` solo cambia por una acción manual, nunca por resize; no se persiste la ocultación contextual. El raíl tiene etiquetas accesibles, tooltips al foco/hover y expansión temporal con etiquetas.
Cabecera sticky y columna contextual con scroll propio conservan una única zona principal de scroll. El foco de enlaces/activadores que desaparecen se traslada a un equivalente visible; los menús que pasan a fijos liberan modalidad. Se conserva el foco y estado del contenido que permanece montado.
V01 sigue parcialmente pendiente: V01.3/V01.4 deben resolver tabla/panel por capacidad y migración/regresión final. `inventory`, `dashboard` y `useCompactDetail` mantienen sus contratos; abrir un panel no cambia los modos del shell.
Usar [BrowserLink y rutas existentes](docs/rutas-del-cliente.md) para destinos reales; botones para acciones, selección local y modales. Conservar acceso directo, recarga, Atrás/Adelante, clic modificado y sección activa accesible. No inventar rutas de edición o fichas para entidades que no las tienen. Si el esquema narrativo de rutas queda desactualizado, contrastarlo con `workspace/shared/helpers/workspace-routes.ts`; no cambiar el modelo de negocio para adaptar una URL.
@ -234,8 +238,8 @@ No declarar cumplimiento WCAG global a partir de tokens, snapshots, capturas o u
Vigente: distribución por contenedor para grupos de formulario, orden DOM conservado, acciones accesibles, nombres que puedan partirse y tabla desplazable dentro de su área. Los umbrales concretos de componentes como `TemplateMetadataSection` están en el patrón de diálogos; no son breakpoints globales nuevos.
- Escritorio amplio: mostrar datos comparables y paneles cuando quepan; evitar márgenes ornamentales y tarjetas sin función.
- Portátil/tablet: evaluar el ancho restante después de las navegaciones y el panel. Los 768/1100 px actuales no garantizan espacio operativo, V01.
- Móvil: contenido y acciones alcanzables sin desbordamiento global horizontal; la tabla puede tener scroll propio. V01.1 mantiene compañía y sección en una franja sticky, y sus destinos en Sheet; la adaptación por capacidad de portátil/tablet sigue pendiente.
- Portátil/tablet: aplicar la política del shell V01.2 y evaluar después el ancho restante para el panel. Los 1100 px de inventario siguen sin garantizar capacidad operativa, pendiente V01.3.
- Móvil: contenido y acciones alcanzables sin desbordamiento global horizontal; la tabla puede tener scroll propio. V01.1 mantiene compañía y sección en una franja sticky, y sus destinos en Sheet; la navegación de portátil/tablet sigue V01.2 y la adaptación del panel sigue pendiente V01.3.
- Ventanas bajas: altura dinámica, cuerpo desplazable y pie visible; no reducir campos para hacerlos caber. El presupuesto de ayuda y la versión compacta de progreso siguen pendientes, V04.
- Formularios y tablas extensas: usar grupos/pestañas/componentes existentes; truncar solo con acceso al valor completo cuando sea necesario. La búsqueda del selector de columnas, V08, y el resumen de ficha, V10, requieren implementación o diseño adicional.
@ -255,7 +259,7 @@ V02, V03, V06 y V09 están **resueltos en los escenarios verificados del sprint
| ID | Tipo y alcance | Próxima decisión o comprobación |
| --- | --- | --- |
| V01 | Implementación parcial: V01.1 móvil | Móvil verificado; pendientes política de portátil/tablet, tabla/panel por capacidad y regresión final de V01 |
| V01 | Implementación parcial: V01.1 móvil + V01.2 navegación por capacidad | Pendientes V01.3 tabla/panel y V01.4 migración/regresión final; no está cerrado |
| V02 | Resuelto: combinación de tokens/estados del inventario claro | Contraste y foco comprobados; no equivale a validar todos los temas o tablas |
| V03 | Resuelto: adopción de patrón y pie estable | Tres pasos de activos/recursos verificados en los cuatro tamaños del sprint |
| V04 | Diseño de ayuda/progreso compacto + componentes | Acordar presupuesto de altura sin perder ayuda junto al pie; verificar 320×640 |

View File

@ -143,14 +143,70 @@
background-color: var(--popover);
}
/* La navegación móvil comparte un único scroll y reserva el bloque sticky al enfocar. */
@media (width < 768px) {
[data-slot="workspace-scroll"] {
scroll-padding-block-start: 128px;
}
/* La política discreta de capacidad vive en workspace-navigation-capacity.ts. */
[data-slot="workspace"] {
--workspace-header-offset: 3.5rem;
}
[data-workspace-main],
[data-workspace-main] :is(h1, h2, input, button, a, select, textarea) {
scroll-margin-block-start: 128px;
.workspace-global-navigation[data-mode="overlay"] {
display: none;
}
.workspace-company-layout {
display: grid;
min-height: calc(100dvh - var(--workspace-header-offset));
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr;
grid-template-areas: "context" "main";
}
.company-fixed-navigation {
display: none;
}
.workspace-company-main {
grid-area: main;
border-top: 1px solid var(--border);
}
.workspace-company-layout[data-navigation="fixed"] {
grid-template-columns: 240px minmax(0, 1fr);
grid-template-rows: 1fr;
grid-template-areas: "navigation main";
}
.workspace-company-layout[data-navigation="fixed"] > .company-fixed-navigation {
display: flex;
position: sticky;
top: var(--workspace-header-offset);
grid-area: navigation;
align-self: start;
min-height: 0;
height: calc(100dvh - var(--workspace-header-offset));
flex-direction: column;
overflow: hidden;
}
.workspace-company-layout[data-navigation="fixed"] > [data-slot="company-context-bar"] {
display: none;
}
.workspace-company-layout[data-navigation="fixed"] > .workspace-company-main {
border-top: 0;
border-left: 1px solid var(--border);
}
[data-slot="workspace-scroll"] {
scroll-padding-block-start: calc(var(--workspace-header-offset) + 3rem);
}
[data-workspace-main],
[data-workspace-main] :is(h1, h2, input, button, a, select, textarea) {
scroll-margin-block-start: calc(var(--workspace-header-offset) + 3rem);
}
@media (width >= 768px) {
[data-slot="workspace"] {
--workspace-header-offset: 88px;
}
}

View File

@ -470,3 +470,11 @@ Se consultaron frontend-design, shadcn, vercel-composition-patterns y
vercel-react-best-practices: se conservaron identidad y tokens, se usó la API pública
Base UI para el foco y se mantuvieron composición y estado remoto existentes. No se
modificaron vendor, autenticación, modelo de datos, rutas ni los otros ocho hallazgos.
## Seguimiento V01.2 — Navegación por capacidad
El 21 de octubre de 2026 se implementa la [segunda entrega V01.2](navegacion-responsive-v01-2.md), sobre V01.1. V01 queda **parcialmente resuelto**, sin cerrar el panel/diálogo de inventario ni la migración de recursos (V01.3/V01.4).
La nueva matriz sintética de cinco pantallas y once resoluciones conserva las mediciones originales de esta auditoría como historia. En Activos, main pasa de 972 a 1128 px en 1440, de 556 a 952 en 1024 y de 300 a 768 en 768. El móvil conserva Y=106; no se registra desbordamiento horizontal del documento en las 55 mediciones posteriores.
Se mantienen los 21 destinos y sus contadores; se verifican preferencia manual frente a colapso automático, intención contextual temporal, scroll independiente, tooltips, enlaces, teclado, foco y resize. La regresión detectada en activadores que desaparecen tenía una prueba que dejaba el foco en BODY; su corrección traslada el foco a un activador/enlace visible. Resultados de regresiones V02/V03/V06/V09, comandos, capturas y límites se registran en el informe de entrega, sin convertir los checks globales fallidos en pruebas aprobadas.

View File

@ -278,17 +278,18 @@ conserva las acciones de identidad y selección de idioma. El avatar muestra ini
del usuario real, sin inventar foto ni cargo. Configuración abre un FormDialog con las acciones existentes.
La sidebar global permite contraerse en escritorio de 228px a 72px mediante
«Contraer menú» / «Expandir menú». Conserva iconos, nombres accesibles y títulos
de ayuda también para compañías y subcatálogos. La navegación se desplaza por
«Contraer menú» / «Expandir menú». Conserva iconos, nombres accesibles y tooltips al foco/hover también para compañías y subcatálogos. La navegación se desplaza por
separado, manteniendo Configuración y el control de anchura en el pie visible.
En móvil, `WorkspaceNavigationContent` reutiliza esos destinos dentro de `WorkspaceMobileSheet`. `WorkspaceMobileNavigationProvider` controla una única raíz Sheet, con estado efímero de panel/apertura y retorno de foco. Cambiar compañía desde el Sheet contextual cambia al global sin apilar dos modales; Escape/cerrar restaura el disparador y navegar enfoca el contenido. Cruzar a 768 px cierra la modalidad y devuelve foco a un destino visible. No se persiste apertura ni se modifica el colapso desktop.
En móvil, `WorkspaceNavigationContent` reutiliza esos destinos dentro de `WorkspaceMobileSheet`. `WorkspaceMobileNavigationProvider` controla una única raíz Sheet, con estado efímero de panel/apertura y retorno de foco. Cambiar compañía desde el Sheet contextual cambia al global sin apilar dos modales; Escape/cerrar restaura el disparador y navegar enfoca el contenido. V01.2 cierra la modalidad cuando la navegación pasa a fija, conservándola si sigue superpuesta; el foco va a un equivalente visible. No se persiste apertura ni se sobrescribe la preferencia con colapsos automáticos.
`useCompanyNavigationEntries` concentra los 21 destinos y traducciones; `CompanySectionNavigation` los presenta como enlaces reales cuando existe navegación por URL y botones en el modo local. `CompanyContextBar` muestra nombre completo accesible, sección y activador contextual, con tooltip al foco/hover para nombres truncados. `CompanyNavigationSheet` conserva contadores y edición con las mismas restricciones previas. `WorkspaceSkipLink` lleva al `main` activo. La tabla y `useCompactDetail` no cambian. Evidencias y límites: [V01.1](navegacion-movil-v01-1.md).
`useCompanyNavigationEntries` concentra los 21 destinos y traducciones; `CompanySectionNavigation` los presenta como enlaces reales cuando existe navegación por URL y botones en el modo local. `CompanyContextBar` muestra nombre completo accesible, sección y activador contextual, con tooltip al foco/hover para nombres truncados. `CompanyNavigationSheet` conserva contadores y edición con las mismas restricciones previas. `WorkspaceSkipLink` lleva al `main` activo. `CompanyFixedNavigation` limita identidad y destinos al alto útil, con scroll independiente. `CompanyDetailBody` mantiene el landmark/contenido estable al cambiar los menús. La tabla y `useCompactDetail` no cambian. Evidencias y límites: [V01.1](navegacion-movil-v01-1.md) y [V01.2](navegacion-responsive-v01-2.md).
La política propia `workspace-navigation-capacity.ts` centraliza 920/1160/1660 a partir de los presupuestos aprobados. `useWorkspaceCapacity` observa un único contenedor; el provider aplica global expandida/raíl/Sheet y contextual fija/Sheet. La franja aparece cuando la secundaria es superpuesta, también en tablet/portátil, y el raíl permite abrir la global etiquetada en Sheet. Ocultar/Fijar secciones expresa intención temporal, conservada al redimensionar sin nueva clave de almacenamiento. El helper de preferencia pasa a `workspace/shared/helpers/sidebar-preference.ts` para ser compartido por el coordinador sin depender de la operación view.
El plegado se conserva entre recargas y sesiones mediante la clave
`metis-desk:sidebar-collapsed` de `localStorage`, compartida por las sesiones de
ese navegador y origen, no por cuenta ni mediante el servidor. Solo `true` inicia
la barra plegada; valores ausentes o inválidos mantienen el estado desplegado.
ese navegador y origen, no por cuenta ni mediante el servidor. Solo `true` solicita
la barra plegada; valores ausentes o inválidos solicitan el estado desplegado, que solo se aplica cuando hay capacidad.
Se guarda desde la acción de plegar/desplegar. Si el navegador bloquea la lectura
o la escritura, la barra sigue funcionando en memoria. No cambia el layout móvil.

View File

@ -0,0 +1,147 @@
# V01.2 — Navegación responsive en portátil y tablet
21 de octubre de 2026. Segunda entrega de la [arquitectura B híbrida](propuesta-navegacion-responsive-v01.md). Completa la adaptación de las navegaciones; V01 sigue parcialmente resuelto y quedan pendientes V01.3/V01.4.
## Arquitectura y alcance
La navegación global presenta tres modos: expandida de 228 px, raíl de 72 px y Sheet sin reserva lateral. La navegación de compañía presenta una columna de 240 px o el Sheet existente. Los destinos se comparten entre presentaciones: no se copian los 21 enlaces, contadores, permisos ni consultas. Rutas, filtros y paginación mantienen sus contratos.
`WorkspaceMobileNavigationProvider` amplía el coordinador de V01.1: conserva una sola raíz Sheet y añade la preferencia manual global, intención contextual temporal y modos efectivos. `useWorkspaceCapacity` observa únicamente el contenedor del shell con un ResizeObserver, publica bandas discretas y se desconecta al desmontar. No almacena anchuras en React ni observa tablas o paneles. Las clases/CSS responden al modo publicado; no hay otra colección de umbrales por componente.
Los nombres Mobile de los componentes de V01.1 se mantienen para evitar una migración de rutas/imports ajena a la entrega; sus presentaciones superpuestas ahora se reutilizan también en tablet y portátil.
La columna contextual agrupa identidad, edición, ocultación y destinos en `CompanyFixedNavigation`. `CompanyDetailBody` mantiene el landmark y la identidad de contenido separados de la presentación de menús, con key estable por sección. Su navegación tiene scroll independiente; el contenido conserva una zona principal de scroll. La cabecera es sticky también en escritorio. La franja contextual solo aparece cuando la columna está superpuesta: mantiene compañía, sección y activador sin duplicar una segunda cabecera cuando ya existe columna fija.
El raíl conserva nombres accesibles y tooltips al foco/hover, incluidos compañías y catálogos. Ofrece Menú global y expansión temporal con etiquetas en Sheet. En los tamaños sin raíl el activador está en la cabecera. Búsqueda y cuenta conservan su comportamiento y traducciones es/en.
No se modifica vendor, backend, autenticación, permisos, dependencias, inventario ni selección/detalle de activos y recursos. Los breakpoints `inventory=1100`, `dashboard=1200` y `useCompactDetail` siguen iguales.
## Presupuestos y modos
La fuente única de la política es `workspace-navigation-capacity.ts`. Se contrasta con el ancho **del shell**, sin asumir que coincide siempre con el viewport:
| Capacidad del shell | Global efectiva | Compañía efectiva por defecto |
| --- | --- | --- |
| <920 px | Sheet | Sheet |
| ≥920 y <1160 px | Raíl 72 | Sheet |
| ≥1160 y <1660 px | Raíl 72 | Columna 240 |
| ≥1660 px | 228 o 72 según preferencia | Columna 240 |
Se conserva además la composición de cabecera de 768 px de V01.1: nominal 56 px por debajo y 88 px desde esa anchura. No es otro presupuesto horizontal de navegación.
Las sumas de referencia son 72+48+800=920, 72+240+48+800=1160 y 228+240+48+800+24+320=1660. Los 800 px son una referencia operativa, no un mínimo absoluto para dispositivos físicos ni una nueva política de panel. Bordes y distribución real se registran en la matriz; no se presenta la suma teórica como medición.
La misma política se aplica en dashboard/directorio y compañía, evitando que cambiar de sección expanda/contraiga el raíl automáticamente. Ocultar la secundaria libera 240 px; no fuerza expansión de la global. Abrir el detalle de inventario tampoco altera los modos del shell.
## Preferencias e intención
- Solo se conserva la clave existente `metis-desk:sidebar-collapsed`. `true` solicita raíl; `false`, ausencia o valor inválido conserva la expansión predeterminada cuando cabe.
- La adaptación automática no escribe localStorage. Al recuperar capacidad vuelve a aplicarse la preferencia manual. La acción de contraer/expandir en ancho amplio sigue siendo la única que guarda esa elección.
- Cuando no cabe la global expandida, Expandir menú abre un Sheet temporal: no sustituye la preferencia guardada ni resta ancho al contenido.
- Ocultar secciones conserva una intención temporal en el workspace. Sobrevive a cambios de anchura y de compañía dentro de ese montaje; no se persiste y se reinicia al recargar. Fijar secciones vuelve a la columna cuando cabe.
- Si no cabe la columna, Fijar secciones permanece deshabilitado con explicación. Ganar espacio no sobrescribe una ocultación manual.
- Apertura de Sheet, compañía activa y mediciones no se incorporan a las preferencias. Los errores de acceso/escritura de localStorage mantienen navegación en memoria.
## Foco, modalidad y estabilidad
Base UI mantiene trap, inert y bloqueo del fondo. Los destinos siguen siendo BrowserLink reales: Enter, clic modificado, clic central, URL directa e historial conservan su semántica. Escape/cerrar restaura el origen; navegar de manera ordinaria enfoca el contenido.
Al ensanchar, un menú cuya presentación pasa a fija se cierra y busca el enlace equivalente, o su destino activo. Si continúa necesitando Sheet, permanece abierto. Al estrechar nunca se abre por sí solo: los enlaces o activadores que desaparecen trasladan foco a un control visible equivalente. Si el foco continúa en el contenido o un formulario, se conserva.
La resolución de foco se realiza después del commit, sobre la presentación realmente visible. Los elementos de navegaciones ocultas no forman parte del recorrido de teclado. El cambio contextual → global usa la misma raíz modal, sin dos menús simultáneos ni retorno intermedio al fondo.
Cabecera/franja y foco emplean el offset del shell, scroll-padding y scroll-margin. Los cambios de modo no cambian el key del contenido ni sus filtros, búsqueda, selección o formularios.
## Mediciones antes/después
Línea base: V01.1 cargada por URL directa, una compañía y un activo sintéticos, sin preferencias. Cinco pantallas —dashboard, directorio, resumen, listado y ficha de activo— en once resoluciones: 55 mediciones y capturas por fase. Se esperan fuentes, frames y transiciones antes de medir.
| Resolución | Global / compañía | Ancho main antes → después | Tabla útil después | Y de main después |
| --- | --- | --- | --- | --- |
| 1920×1080 | 228 / 240 | 1452 → 1452 | 1401 | 88 |
| 1660×900 | 228 / 240 | 1192 → 1192 | 1141 | 88 |
| 1440×900 | 72 / 240 | 972 → 1128 | 1077 | 88 |
| 1366×768 | 72 / 240 | 898 → 1054 | 1003 | 88 |
| 1160×800 | 72 / 240 | 692 → 848 | 797 | 88 |
| 1100×800 | 72 / Sheet | 632 → 1028 | 978 | 137 |
| 1024×768 | 72 / Sheet | 556 → 952 | 902 | 137 |
| 920×768 | 72 / Sheet | 452 → 848 | 798 | 137 |
| 768×1024 | Sheet / Sheet | 300 → 768 | 718 | 137 |
| 390×844 | Sheet / Sheet | 390 → 390 | 356 | 106 |
| 320×640 | Sheet / Sheet | 320 → 320 | 286 | 106 |
No se registra overflow horizontal del documento en ninguna de las 55 mediciones posteriores. En los límites nominales la tabla útil mide 797–798 px, 2–3 px menos que la referencia de 800 por bordes/distribución; se mantienen los presupuestos redondos como prioridades, no mínimos absolutos. No se introducen umbrales adicionales para compensar esa diferencia.
El móvil conserva la geometría de V01.1. Los controles intermedios dejan de reservar ambas columnas; no se confunde esta recuperación con la resolución del panel de inventario.
## Pruebas y resultados
Se reutiliza el procedimiento `scripts/visual-audit.py` y el cliente Playwright MCP mediante Docker/Chromium. Se prepara una única instancia sintética de auditoría; la BD de tests globales se aísla en otro contenedor. No se usa la BD local real ni se crean usuarios adicionales para ampliar la cobertura.
`sidebar-preference.test.ts` conserva las pruebas de valores inválidos/almacenamiento inaccesible y añade límites, preferencia frente a modo efectivo e intención contextual. `scripts/visual-audit-ui.test.py` amplía las pruebas ya existentes, sin framework paralelo.
La validación cubre límites a ambos lados, los 21 destinos, teclado, foco, tooltip, preferencias y recarga, ocultación/fijación, resize, conservación de tabla/formulario, scroll independiente, traducciones y nombres largos. Se repiten las regresiones V02/V03/V06/V09 con datos sintéticos. Los intentos fallidos de desarrollo no se cuentan como pasadas aprobadas.
**Suite final completa: 16 pruebas Playwright MCP aprobadas en 453,516 s.** Incluye las regresiones V01.1, V02, V03, V06 y V09. La consola final registra cero errores y cero avisos. La comprobación semántica de orientación confirma compañía y sección visibles en once resoluciones; sus anchos/Y finales coinciden con la matriz posterior tras los últimos ajustes de composición.
Pasan las cinco pruebas ejecutadas directamente de preferencia/capacidad y las doce pruebas de `app.test.tsx`, además del caso dirigido de navegación de compañía es/en. La suite global de Node mantiene dos expectativas antiguas fallidas de `companies-page.test.tsx`; no se confunde el éxito de los casos dirigidos con el éxito de toda la suite.
| Comando / comprobación | Resultado real |
| --- | --- |
| `pnpm --filter @metis/companies typecheck` | Pasa |
| `pnpm --filter @metis/web typecheck` | Pasa |
| Biome dirigido, 20 archivos propios TS/TSX/JSON | Pasa sin errores ni avisos |
| ESLint de espaciado, 18 archivos TS/TSX | Pasa |
| `pnpm build` | Pasa; conserva aviso de chunk grande |
| `pnpm typecheck` | Falla en vendor `calendar.tsx:165`, incompatibilidad locale/exactOptionalPropertyTypes ya documentada; vendor intacto |
| `pnpm lint` | Falla: 15 errores, un aviso y cuatro informaciones en archivos ajenos, como fotos/procesos/migraciones/skills |
| `pnpm check` | Falla: 168 errores, un aviso y cuatro informaciones de formato/reglas globales ajenas; propios comprobados separadamente |
| `pnpm test` con MariaDB aislada | Falla en las expectativas previas «Personas (1)» y «Esta persona no tiene acceso a Metis Desk». Turbo termina con 7 tareas correctas de 8 y no acredita las suites que el fallo interrumpe |
Estos fallos globales ya figuran en el seguimiento del sprint 1 y V01.1; se contrastan los diagnósticos, no se atribuye automáticamente cualquier fallo a deuda previa. Los contenedores de tests se retiraron al finalizar. El preparador visual cerró sus servicios, retiró su BD y borró sesión/credenciales al terminar la auditoría; se conservaron los contenedores previos ajenos. No se corrigen archivos ajenos para conseguir checks globales verdes.
Se reprodujo con una prueba adicional el foco en BODY al desaparecer el activador entre 390→768; después de corregir el reconocimiento de los activadores, pasan los cuatro escenarios de cambio de cabecera/contexto. Se mantienen la prueba fallida previa y la posterior como evidencia de la corrección. También se corrigieron errores del propio guion MCP —ejecución DOM fuera de page.evaluate, nombres de botones y esperas de frames—; no se contabilizan como defectos de Metis ni como pruebas aprobadas.
Para verificar cambio de compañía se creó una segunda compañía exclusivamente en la BD sintética, mediante el formulario real. La excepción HTTP del guion permite solo ese POST de fixture; los demás formularios se cancelan y no se realizan borrados. La matriz antes/después se tomó antes de añadir esa segunda compañía. No se crean usuarios adicionales.
Para repetir, mantener abierto el preparador y usar la ruta privada que imprima:
```bash
METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
METIS_UI_AUDIT_DIRECTORY=.artifacts/playwright/v01-2/after \
python3 scripts/visual-audit-ui.test.py -v
```
Sustituir ID por la sesión real. No versionar ni compartir esa sesión.
## Evidencias y límites
Evidencias locales excluidas de Git, bajo `.artifacts/playwright/v01-2/`. Las capturas se abren y examinan: geometría, snapshot y comportamiento no son pruebas intercambiables. Los logs identifican fallos previos y regresiones corregidas de esta entrega.
- [Mediciones antes](../.artifacts/playwright/v01-2/before/measurements.json) y [después](../.artifacts/playwright/v01-2/after-layout/measurements.json), cinco pantallas por once resoluciones.
- [Orientación y geometría finales](../.artifacts/playwright/v01-2/orientation-final.json), identidad y sección visibles en los once tamaños. Los campos auxiliares heredados `identity*` del primer muestreador no se usan para concluir visibilidad: no resuelven la identidad de la nueva composición. Esta comprobación usa franja o encabezado fijo según el modo real.
- [Activos 768 antes](../.artifacts/playwright/v01-2/before/assets-768.png), [después](../.artifacts/playwright/v01-2/after-layout/assets-768.png), [raíl y secundaria en 1440](../.artifacts/playwright/v01-2/after-layout/assets-1440.png), [franja y Sheet contextual en 1024](../.artifacts/playwright/v01-2/final/responsive-context-overlay-1024.png).
- [Tooltip con teclado](../.artifacts/playwright/v01-2/final/responsive-tooltip-focus-1366.png), [último destino y scroll propio](../.artifacts/playwright/v01-2/final/responsive-last-destination-1366.png), [nombre largo](../.artifacts/playwright/v01-2/final/navigation-long-name-1366.png), [segunda compañía](../.artifacts/playwright/v01-2/final/responsive-second-company-768.png).
- [Últimas mediciones interactivas por escenario](../.artifacts/playwright/v01-2/final/summary-latest.json), [consola final](../.artifacts/playwright/v01-2/final/console.json), [índice de capturas](../.artifacts/playwright/v01-2/evidence-index.json) y [log de las 16 pruebas](../.artifacts/playwright/v01-2/technical/visual-ui-final.log). Los subdirectorios de sondeo/desarrollo no sustituyen la suite final.
A 1100 px se conserva el panel de 320 y la tabla queda en 634; a 1024 se conserva el diálogo, de aproximadamente 922 px. [Captura del límite pendiente](../.artifacts/playwright/v01-2/panel-limitation/inventory-selection-1100.png). No se presenta esta tabla todavía estrecha como un problema resuelto por V01.2.
Para compartir evidencia, entregar únicamente el directorio `v01-2/`; excluir `authenticated-*`, sesiones y credenciales. Se revisan las cabeceras PNG y dimensiones al generar el índice. La configuración MCP permanece sin cambios.
No se declara cumplimiento WCAG ni verificación con lector de pantalla. El zoom real de Chromium headless no se considera probado por ampliar la fuente raíz; se conserva esa distinción de V01.1. Tampoco se prueba Cmd+clic en macOS, Docker Desktop/arm64 o todas las combinaciones de roles/compañías/tema oscuro.
V01.3 sigue pendiente: la política actual de panel/diálogo depende del viewport, no de la capacidad del inventario tras las navegaciones. V01.4 —migración de recursos y regresión final global de V01— tampoco se implementa. No se abordan V04–V12.
## Archivos principales y documentación
- Shell: `companies-workspace`, `workspace-sidebar`, `workspace-header`, `workspace-navigation-content`, `workspace-search`, `workspace-navigation-button` y el provider/context de V01.1.
- Política/DOM: `workspace-navigation-capacity.ts`, `use-workspace-capacity.ts` y `workspace-navigation-focus.ts`. El helper de preferencia se mueve, sin cambiar su contrato, de view a shared para que el coordinador no dependa de una operación superior.
- Compañía: `company-detail`, `company-detail-body`, `company-fixed-navigation`, `company-context-bar` y `company-navigation-sheet`.
- Estilos locales de composición en `apps/web/src/global.css`, textos en `common/locales/es.json` y `en.json`, pruebas de preferencias y el guion MCP existente.
- Documentación actualizada: DESIGN, componentes, propuesta V01, seguimiento de auditoría y descripción responsive de rutas. No cambia ningún destino.
## Skills y APIs consultadas
`ux-navigation` dirige orientación, ámbitos, etiquetas y descubribilidad; `frontend-design` conserva jerarquía e identidad existentes; `shadcn` guía Sheet y Tooltip sobre sus APIs instaladas. Las reglas de composición y React sustentan el coordinador compartido, estado mínimo y una suscripción discreta con cleanup. No se consulta Pro Max sin una duda específica ni se genera otra paleta/sistema visual.
La CLI informativa `pnpm dlx shadcn@latest docs sheet tooltip` falla por DNS. Se consultan las [docs de Sheet](https://ui.shadcn.com/docs/components/base/sheet), [Tooltip](https://ui.shadcn.com/docs/components/base/tooltip) y [Dialog/Base UI](https://base-ui.com/react/components/dialog), contrastadas con vendor instalado, sin modificarlo. Los auditores especializados no instalados no se presentan como ejecutados; la comprobación interactiva se realiza con MCP.

View File

@ -2,7 +2,7 @@
Fecha: 21 de octubre de 2026. **Arquitectura B híbrida aprobada para entregas incrementales. V01 continúa parcialmente pendiente.**
Seguimiento: [V01.1 — navegación móvil y franja contextual](navegacion-movil-v01-1.md) implementa la primera entrega, conservando escritorio y umbrales existentes. El resto del documento conserva las alternativas, mediciones originales y arquitectura objetivo; los presupuestos de capacidad y las entregas V01.2–V01.4 no están implementados.
Seguimiento: [V01.1 — navegación móvil y franja contextual](navegacion-movil-v01-1.md) implementa la primera entrega, conservando escritorio y umbrales existentes. [V01.2 — navegación responsive](navegacion-responsive-v01-2.md) implementa después la política de navegación por capacidad en portátil/tablet. El resto del documento conserva alternativas, mediciones originales y arquitectura objetivo; V01.3/V01.4 siguen pendientes. Las tablas originales describen la propuesta, no sustituyen las mediciones posteriores de las entregas.
## 1. Resumen ejecutivo

View File

@ -156,3 +156,7 @@ Tickets, intervenciones y plantillas de intervención no tienen rutas activas. S
Información e Historial. El listado conserva la selección local para su panel lateral;
los enlaces de nombre y las acciones de ver abren la página. Los parámetros `q` y
`template` se conservan en los enlaces del listado y de retorno a este.
## Presentaciones responsive de la navegación
[V01.2](navegacion-responsive-v01-2.md) cambia la presentación de los enlaces existentes según capacidad del shell: global expandida, raíl o Sheet; compañía fija o Sheet. No añade destinos ni utiliza Select para navegar. Compañía/sección/ficha siguen en la URL; apertura y ocultación contextual son temporales. Enlaces modificados mantienen nueva pestaña e historial; navegar ordinariamente desde Sheet lo cierra y enfoca contenido. El resize conserva la URL y el estado local del contenido.

View File

@ -1,5 +1,10 @@
{
"companies": {
"responsiveNavigation": {
"hideSections": "Hide sections",
"pinSections": "Pin sections",
"pinUnavailable": "Widen the window to keep sections visible beside the content."
},
"mobileNavigation": {
"global": "Global menu",
"search": "Search Metis Desk",

View File

@ -1,5 +1,10 @@
{
"companies": {
"responsiveNavigation": {
"hideSections": "Ocultar secciones",
"pinSections": "Fijar secciones",
"pinUnavailable": "Amplía la ventana para mantener las secciones visibles junto al contenido."
},
"mobileNavigation": {
"global": "Menú global",
"search": "Buscar en Metis Desk",

View File

@ -25,7 +25,7 @@ export function CompanyContextBar({
return (
<div
className="sticky top-14 z-20 flex min-h-12 min-w-0 items-center gap-2 border-b border-border bg-background px-3 py-0.5 [grid-area:context] shell:hidden"
className="sticky top-[var(--workspace-header-offset)] z-20 flex min-h-12 min-w-0 items-center gap-2 border-b border-border bg-background px-3 py-0.5 [grid-area:context]"
data-slot="company-context-bar"
>
<CompanyAvatar
@ -55,6 +55,7 @@ export function CompanyContextBar({
aria-expanded={navigation?.open && navigation.panel === "company"}
aria-label={t("mobileNavigation.company", { company: companyName })}
className="min-h-11 shrink-0"
data-workspace-navigation-trigger="company"
onClick={(event) => navigation?.show("company", event.currentTarget)}
variant="ghost"
>

View File

@ -0,0 +1,34 @@
import type { ComponentProps } from "react";
import { CompanyIdentity } from "../../shared";
import { CompanySection } from "./company-section";
/** Mantiene el landmark y la identidad del contenido independientes de la presentación de los menús. */
export function CompanyDetailBody({
embedded,
...sectionProps
}: ComponentProps<typeof CompanySection> & { embedded: boolean }) {
const { company } = sectionProps;
return (
<main
className={
embedded ? "workspace-company-main min-w-0 bg-background p-4 shell:p-6" : undefined
}
{...(embedded ? { id: "workspace-main", tabIndex: -1, "data-workspace-main": "" } : {})}
>
{!embedded && (
<div className="mb-4 border-b border-border pb-4 text-sm leading-[1.5] text-muted-foreground shell:mb-6">
<CompanyIdentity
className="size-6"
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
</div>
)}
<CompanySection {...sectionProps} />
</main>
);
}

View File

@ -4,15 +4,16 @@ import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { WorkspaceMobileNavigationContext } from "../../../workspace/shared/workspace-mobile-navigation-context";
import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
import { CompanyEditorDialog, CompanyFeedback } from "../../shared";
import { useCompanyDetailController } from "../controllers";
import { useCompanyNavigationEntries } from "../helpers/use-company-navigation-entries";
import type { CompanyDetailProps } from "../types";
import { CompanyContextBar } from "./company-context-bar";
import { CompanyDetailBody } from "./company-detail-body";
import { CompanyFixedNavigation } from "./company-fixed-navigation";
import { CompanyHeader } from "./company-header";
import { CompanyNavigationSheet } from "./company-navigation-sheet";
import { CompanySection } from "./company-section";
import { CompanySectionNavigation } from "./company-section-navigation";
export function CompanyDetail(props: CompanyDetailProps) {
@ -32,22 +33,37 @@ export function CompanyDetail(props: CompanyDetailProps) {
const sectionLabel = entries.find(([section]) => section === tab)?.[1] ?? "";
const initialContext = Boolean(props.section) || tab === initialSection;
const layout = embedded
? {
root: "grid min-h-[calc(100dvh-140px)] grid-cols-[minmax(0,1fr)] grid-rows-[auto_1fr] [grid-template-areas:'context'_'main'] shell:min-h-[calc(100dvh-var(--spacing-shell-header))] shell:grid-cols-[240px_minmax(0,1fr)] shell:grid-rows-[auto_1fr] shell:[grid-template-areas:'heading_main'_'navigation_main']",
heading:
"hidden border-b border-border [grid-area:heading] shell:block shell:px-4 shell:py-5",
navigation: "hidden [grid-area:navigation] shell:block",
main: "min-w-0 border-t border-border bg-background p-4 [grid-area:main] shell:border-l shell:border-t-0 shell:p-6",
}
: {
root: "space-y-6",
heading: "flex flex-wrap items-start justify-between gap-4",
navigation: "contents",
main: undefined,
};
? ({ root: "workspace-company-layout", section: "sidebar", edit: "mt-4 w-full" } as const)
: ({ root: "space-y-6", section: "inline", edit: undefined } as const);
const initialValues = initialContext
? { initialCreate, initialEntityId }
: { initialCreate: false, initialEntityId: undefined };
const heading = company && (
<>
<CompanyHeader
activeLabel={t("activeCompany")}
company={company}
embedded={embedded}
inactiveLabel={t("inactiveCompany")}
/>
<Button className={layout.edit} onClick={() => setEditOpen(true)} variant="outline">
{t("editCompany")}
</Button>
</>
);
const sectionNavigation = company && (
<CompanySectionNavigation
activeSection={tab}
companyId={company.id}
counts={counts}
entries={entries}
layout={layout.section}
{...(props.onSectionChange ? {} : { onSectionChange: setTab })}
/>
);
return (
<div className={layout.root}>
<div className={layout.root} data-navigation={navigation?.companyMode ?? "fixed"}>
{!embedded && (
<button
className="app-link inline-flex cursor-pointer items-center gap-1 text-sm font-medium"
@ -72,21 +88,11 @@ export function CompanyDetail(props: CompanyDetailProps) {
)}
{company && (
<>
<div className={layout.heading}>
<CompanyHeader
activeLabel={t("activeCompany")}
company={company}
embedded={embedded}
inactiveLabel={t("inactiveCompany")}
/>
<Button
className={embedded ? "shell:mt-4 shell:w-full" : undefined}
onClick={() => setEditOpen(true)}
variant="outline"
>
{t("editCompany")}
</Button>
</div>
{embedded ? (
<CompanyFixedNavigation header={heading}>{sectionNavigation}</CompanyFixedNavigation>
) : (
<div className="flex flex-wrap items-start justify-between gap-4">{heading}</div>
)}
<CompanyEditorDialog
company={company}
onOpenChange={setEditOpen}
@ -116,40 +122,16 @@ export function CompanyDetail(props: CompanyDetailProps) {
</CompanyNavigationSheet>
</>
)}
<div className={layout.navigation}>
<CompanySectionNavigation
activeSection={tab}
companyId={company.id}
counts={counts}
entries={entries}
layout={embedded ? "sidebar" : "inline"}
{...(props.onSectionChange ? {} : { onSectionChange: setTab })}
/>
</div>
{!embedded && sectionNavigation}
<main
className={layout.main}
<CompanyDetailBody
company={company}
embedded={embedded}
initialConfiguration={initialConfiguration}
{...initialValues}
key={tab}
{...(embedded ? { id: "workspace-main", tabIndex: -1, "data-workspace-main": "" } : {})}
>
{!embedded && (
<div className="mb-4 border-b border-border pb-4 text-sm leading-[1.5] text-muted-foreground shell:mb-6">
<CompanyIdentity
className="size-6"
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
</div>
)}
<CompanySection
company={company}
initialConfiguration={initialConfiguration}
initialCreate={initialContext ? initialCreate : false}
initialEntityId={initialContext ? initialEntityId : undefined}
tab={tab}
/>
</main>
tab={tab}
/>
</>
)}
</div>

View File

@ -0,0 +1,36 @@
import { Button } from "@repo/shadcn-ui/components/button";
import type { ReactNode } from "react";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { WorkspaceMobileNavigationContext } from "../../../workspace/shared/workspace-mobile-navigation-context";
/** La identidad permanece visible; los 21 destinos desplazan sin arrastrar el contenido. */
export function CompanyFixedNavigation({
header,
children,
}: {
header: ReactNode;
children: ReactNode;
}) {
const navigation = useContext(WorkspaceMobileNavigationContext);
const { t } = useTranslation("companies");
return (
<aside className="company-fixed-navigation" data-company-fixed-navigation="">
<div className="max-h-[50%] shrink-0 overflow-y-auto border-b border-border px-4 py-5">
{header}
<Button
className="mt-2 w-full"
onClick={() => navigation?.setCompanyHidden(true)}
variant="ghost"
>
{t("responsiveNavigation.hideSections")}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto" data-company-navigation-scroll="">
{children}
</div>
</aside>
);
}

View File

@ -30,6 +30,19 @@ export function CompanyNavigationSheet({
<Button className="mt-2" onClick={() => navigation?.show("global")} variant="outline">
{t("mobileNavigation.changeCompany")}
</Button>
<Button
className="mt-2 ms-2"
disabled={!navigation?.canFixCompany}
onClick={() => navigation?.setCompanyHidden(false)}
variant="outline"
>
{t("responsiveNavigation.pinSections")}
</Button>
{!navigation?.canFixCompany && (
<p className="mt-2 text-xs text-muted-foreground">
{t("responsiveNavigation.pinUnavailable")}
</p>
)}
</div>
{children}
<div className="mt-auto shrink-0 border-t border-border p-4">

View File

@ -0,0 +1,65 @@
/** Presupuestos del shell; no deciden la presentación del detalle de inventario. */
export const WORKSPACE_NAVIGATION_BUDGET = {
mobileHeader: 768,
compact: 72 + 48 + 800,
contextual: 72 + 240 + 48 + 800,
expanded: 228 + 240 + 48 + 800 + 24 + 320,
} as const;
export type WorkspaceCapacity = "mobile" | "overlay" | "rail" | "contextual" | "wide";
export type WorkspaceGlobalMode = "expanded" | "compact" | "overlay";
export type WorkspaceCompanyMode = "fixed" | "overlay";
export function workspaceCapacity(width: number): WorkspaceCapacity {
if (width >= WORKSPACE_NAVIGATION_BUDGET.expanded) {
return "wide";
}
if (width >= WORKSPACE_NAVIGATION_BUDGET.contextual) {
return "contextual";
}
if (width >= WORKSPACE_NAVIGATION_BUDGET.compact) {
return "rail";
}
return width >= WORKSPACE_NAVIGATION_BUDGET.mobileHeader ? "overlay" : "mobile";
}
export function workspaceNavigationModes(
capacity: WorkspaceCapacity,
preference: { collapsed: boolean; companyHidden: boolean }
): { globalMode: WorkspaceGlobalMode; companyMode: WorkspaceCompanyMode; canFixCompany: boolean } {
const canFixCompany = capacity === "wide" || capacity === "contextual";
const globalMode =
capacity === "wide" && !preference.collapsed
? "expanded"
: capacity === "mobile" || capacity === "overlay"
? "overlay"
: "compact";
return {
globalMode,
companyMode: canFixCompany && !preference.companyHidden ? "fixed" : "overlay",
canFixCompany,
};
}
export function workspacePanelBecomesFixed(
panel: "global" | "company" | "search",
previous: { globalMode: WorkspaceGlobalMode; capacity: WorkspaceCapacity },
current: {
globalMode: WorkspaceGlobalMode;
companyMode: WorkspaceCompanyMode;
capacity: WorkspaceCapacity;
}
): boolean {
switch (panel) {
case "global":
return current.globalMode !== "overlay" && previous.globalMode !== current.globalMode;
case "company":
return current.companyMode === "fixed";
case "search":
return previous.capacity === "mobile" && current.capacity !== "mobile";
}
}

View File

@ -0,0 +1,65 @@
import { workspaceContentFocusTarget } from "./workspace-content-focus";
export interface WorkspaceNavigationFocus {
panel: "global" | "company" | "search";
href?: string | null | undefined;
}
export function isWorkspaceFocusVisible(element: HTMLElement | null | undefined): boolean {
return Boolean(element?.isConnected && element.getClientRects().length);
}
/** Busca el destino equivalente después del commit, cuando la nueva presentación ya es visible. */
export function workspaceNavigationFocusTarget({
panel,
href,
}: WorkspaceNavigationFocus): HTMLElement | null {
const scope = document.querySelector<HTMLElement>(
panel === "company" ? "[data-company-fixed-navigation]" : "[data-workspace-desktop-navigation]"
);
const links = [...(scope?.querySelectorAll<HTMLElement>("a") ?? [])];
const equivalent = links.find(
(link) => link.getAttribute("href") === href && isWorkspaceFocusVisible(link)
);
const current = links.find(
(link) => link.getAttribute("aria-current") === "page" && isWorkspaceFocusVisible(link)
);
const trigger = [
...document.querySelectorAll<HTMLElement>("[data-workspace-navigation-trigger]"),
].find(
(element) =>
element.dataset.workspaceNavigationTrigger === panel && isWorkspaceFocusVisible(element)
);
if (panel === "search") {
return (
trigger ??
document.querySelector<HTMLElement>("header [data-workspace-inline-search] input") ??
workspaceContentFocusTarget()
);
}
return equivalent ?? current ?? trigger ?? workspaceContentFocusTarget();
}
export function workspaceFixedFocusPanel(
element: HTMLElement | null
): WorkspaceNavigationFocus["panel"] | null {
for (const [selector, panel] of [
[
"[data-company-fixed-navigation], [data-slot=company-context-bar], [data-workspace-navigation-trigger=company]",
"company",
],
[
"[data-workspace-desktop-navigation], [data-workspace-navigation-trigger=global], [data-workspace-navigation-origin=global]",
"global",
],
["[data-workspace-inline-search], [data-workspace-navigation-trigger=search]", "search"],
] as const) {
if (element?.closest(selector)) {
return panel;
}
}
return null;
}

View File

@ -0,0 +1,34 @@
import { useLayoutEffect, useRef, useState } from "react";
import { workspaceCapacity } from "../helpers/workspace-navigation-capacity";
/** Un único observador del shell publica estados discretos, nunca anchuras por píxel. */
export function useWorkspaceCapacity() {
const shellRef = useRef<HTMLDivElement>(null);
const [capacity, setCapacity] = useState(() =>
workspaceCapacity(typeof window === "undefined" ? 1920 : window.innerWidth)
);
useLayoutEffect(() => {
const shell = shellRef.current;
if (!shell) {
return;
}
const update = (width: number) => setCapacity(workspaceCapacity(width));
const observer = new ResizeObserver(([entry]) => {
if (entry) {
update(entry.contentRect.width);
}
});
update(shell.getBoundingClientRect().width);
observer.observe(shell);
return () => observer.disconnect();
}, []);
return { shellRef, capacity };
}

View File

@ -1,19 +1,39 @@
import { Sheet } from "@repo/shadcn-ui/components/sheet";
import type { MouseEvent, ReactNode } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { readSidebarCollapsed, saveSidebarCollapsed } from "../helpers/sidebar-preference";
import { workspaceContentFocusTarget } from "../helpers/workspace-content-focus";
import {
workspaceNavigationModes,
workspacePanelBecomesFixed,
} from "../helpers/workspace-navigation-capacity";
import type { WorkspaceNavigationFocus } from "../helpers/workspace-navigation-focus";
import {
isWorkspaceFocusVisible,
workspaceFixedFocusPanel,
workspaceNavigationFocusTarget,
} from "../helpers/workspace-navigation-focus";
import { useWorkspaceCapacity } from "../hooks/use-workspace-capacity";
import type { WorkspaceMobilePanel } from "../workspace-mobile-navigation-context";
import { WorkspaceMobileNavigationContext } from "../workspace-mobile-navigation-context";
/** Una única raíz modal permite cambiar de menú sin apilar Sheet ni devolver foco al fondo. */
/** Preferencias, capacidad y una única raíz modal; los destinos siguen perteneciendo al consumidor. */
export function WorkspaceMobileNavigationProvider({ children }: { children: ReactNode }) {
const { shellRef, capacity } = useWorkspaceCapacity();
const [collapsed, setCollapsed] = useState(readSidebarCollapsed);
const [companyHidden, setCompanyHidden] = useState(false);
const modes = workspaceNavigationModes(capacity, { collapsed, companyHidden });
const { globalMode, companyMode } = modes;
const [state, setState] = useState<{ panel: WorkspaceMobilePanel; open: boolean }>({
panel: "global",
open: false,
});
const triggerRef = useRef<HTMLElement | undefined>(undefined);
const focusRef = useRef<"trigger" | "content" | "none">("trigger");
const focusRef = useRef<"trigger" | "content" | "none" | "navigation">("trigger");
const navigationFocusRef = useRef<WorkspaceNavigationFocus>({ panel: "global" });
const lastFocusRef = useRef<HTMLElement | null>(null);
const previousModesRef = useRef({ globalMode, companyMode, capacity });
const contentId = useId();
function close(focus: "trigger" | "content" | "none" = "trigger") {
@ -32,6 +52,13 @@ export function WorkspaceMobileNavigationProvider({ children }: { children: Reac
setState({ panel, open: true });
}
function toggleCollapsed() {
const next = !collapsed;
setCollapsed(next);
saveSidebarCollapsed(next);
}
function onNavigate(event: MouseEvent<HTMLElement>) {
if (
!event.defaultPrevented &&
@ -52,41 +79,91 @@ export function WorkspaceMobileNavigationProvider({ children }: { children: Reac
if (focusRef.current === "content") {
return workspaceContentFocusTarget() ?? false;
}
if (focusRef.current === "navigation") {
return workspaceNavigationFocusTarget(navigationFocusRef.current) ?? false;
}
const trigger = triggerRef.current;
return trigger?.isConnected && trigger.getClientRects().length > 0
return trigger && isWorkspaceFocusVisible(trigger)
? trigger
: (document.querySelector<HTMLElement>(
"[data-workspace-desktop-navigation] [aria-current=page]"
) ??
workspaceContentFocusTarget() ??
false);
: (workspaceNavigationFocusTarget({ panel: state.panel }) ?? false);
}
useEffect(() => {
const media = window.matchMedia("(min-width: 768px)");
const onChange = () => {
if (media.matches) {
focusRef.current = "trigger";
setState((previous) => ({ ...previous, open: false }));
const onFocus = (event: FocusEvent) => {
if (event.target instanceof HTMLElement && event.target !== document.body) {
lastFocusRef.current = event.target;
}
};
media.addEventListener("change", onChange);
document.addEventListener("focusin", onFocus);
return () => media.removeEventListener("change", onChange);
return () => document.removeEventListener("focusin", onFocus);
}, []);
// Sincronización DOM tras cambiar de presentación: no deriva estado de contenido ni abre menús.
useLayoutEffect(() => {
const previous = previousModesRef.current;
previousModesRef.current = { globalMode, companyMode, capacity };
if (
previous.globalMode === globalMode &&
previous.companyMode === companyMode &&
previous.capacity === capacity
) {
return;
}
const active =
document.activeElement instanceof HTMLElement && document.activeElement !== document.body
? document.activeElement
: lastFocusRef.current;
const modalBecomesFixed =
state.open &&
workspacePanelBecomesFixed(state.panel, previous, { globalMode, companyMode, capacity });
const fixedPanel = workspaceFixedFocusPanel(active);
if (modalBecomesFixed) {
navigationFocusRef.current = { panel: state.panel, href: active?.getAttribute("href") };
focusRef.current = "navigation";
setState((previousState) => ({ ...previousState, open: false }));
} else if (fixedPanel && !isWorkspaceFocusVisible(active)) {
workspaceNavigationFocusTarget({
panel: fixedPanel,
href: active?.getAttribute("href"),
})?.focus();
}
}, [capacity, globalMode, companyMode, state.open, state.panel]);
return (
<WorkspaceMobileNavigationContext
value={{ ...state, contentId, show, close, onNavigate, finalFocus }}
value={{
...state,
...modes,
capacity,
collapsed,
toggleCollapsed,
setCompanyHidden,
contentId,
show,
close,
onNavigate,
finalFocus,
}}
>
<Sheet
onOpenChange={(open) => setState((previous) => ({ ...previous, open }))}
open={state.open}
>
{children}
<div
className="flex h-dvh min-h-0 flex-col overflow-hidden bg-shell-exterior shell:flex-row shell:rounded-shell"
data-capacity={capacity}
data-company-navigation={companyMode}
data-global-navigation={globalMode}
data-slot="workspace"
ref={shellRef}
>
{children}
</div>
</Sheet>
</WorkspaceMobileNavigationContext>
);

View File

@ -1,7 +1,11 @@
import { BrowserLink } from "@metis/core/client";
import { Button, buttonVariants } from "@repo/shadcn-ui/components/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components/tooltip";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { HTMLAttributes } from "react";
import { useContext } from "react";
import { WorkspaceMobileNavigationContext } from "../workspace-mobile-navigation-context";
export function WorkspaceNavigationButton({
level = "primary",
@ -9,6 +13,7 @@ export function WorkspaceNavigationButton({
href,
...props
}: HTMLAttributes<HTMLElement> & { level?: "primary" | "catalog"; href?: string }) {
const navigation = useContext(WorkspaceMobileNavigationContext);
const navigationClassName = cn(
"shell:group-data-[collapsed=true]/sidebar:justify-center shell:group-data-[collapsed=true]/sidebar:px-0 shell:group-data-[collapsed=true]/sidebar:[&>span:not([data-slot$=avatar])]:hidden",
level === "catalog"
@ -17,27 +22,26 @@ export function WorkspaceNavigationButton({
className
);
if (href) {
const { "aria-pressed": pressed, ...linkProps } = props;
return (
<BrowserLink
{...linkProps}
aria-current={pressed ? "page" : undefined}
className={cn(buttonVariants({ variant: "ghost" }), navigationClassName)}
data-active={pressed}
data-slot="button"
href={href}
/>
);
}
const { "aria-pressed": pressed, ...linkProps } = props;
const control = href ? (
<BrowserLink
{...linkProps}
aria-current={pressed ? "page" : undefined}
className={cn(buttonVariants({ variant: "ghost" }), navigationClassName)}
data-active={pressed}
data-slot="button"
href={href}
/>
) : (
<Button {...props} className={navigationClassName} data-active={pressed} variant="ghost" />
);
return (
<Button
{...props}
className={navigationClassName}
data-active={props["aria-pressed"]}
variant="ghost"
/>
<Tooltip disabled={navigation?.globalMode !== "compact" || navigation.open}>
<TooltipTrigger render={control} />
<TooltipContent role="tooltip" side="right">
{props["aria-label"] ?? props.title}
</TooltipContent>
</Tooltip>
);
}

View File

@ -1,9 +1,22 @@
import type { MouseEvent } from "react";
import { createContext } from "react";
import type {
WorkspaceCapacity,
WorkspaceCompanyMode,
WorkspaceGlobalMode,
} from "./helpers/workspace-navigation-capacity";
export type WorkspaceMobilePanel = "global" | "company" | "search";
export interface WorkspaceMobileNavigation {
capacity: WorkspaceCapacity;
globalMode: WorkspaceGlobalMode;
companyMode: WorkspaceCompanyMode;
canFixCompany: boolean;
collapsed: boolean;
toggleCollapsed: () => void;
setCompanyHidden: (hidden: boolean) => void;
panel: WorkspaceMobilePanel;
open: boolean;
contentId: string;

View File

@ -1,7 +1,15 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { readSidebarCollapsed, saveSidebarCollapsed } from "./sidebar-preference";
import {
readSidebarCollapsed,
saveSidebarCollapsed,
} from "../../shared/helpers/sidebar-preference";
import {
workspaceCapacity,
workspaceNavigationModes,
workspacePanelBecomesFixed,
} from "../../shared/helpers/workspace-navigation-capacity";
function withBrowser(
browser: { readonly localStorage: Pick<Storage, "getItem" | "setItem"> },
@ -85,3 +93,57 @@ test("un fallo de lectura, escritura o acceso a localStorage no bloquea la barra
}
);
});
test("la capacidad del shell fuerza modos sin cambiar la preferencia manual", () => {
const cases = [
[320, "mobile", "overlay", "overlay"],
[767, "mobile", "overlay", "overlay"],
[768, "overlay", "overlay", "overlay"],
[919, "overlay", "overlay", "overlay"],
[920, "rail", "compact", "overlay"],
[1159, "rail", "compact", "overlay"],
[1160, "contextual", "compact", "fixed"],
[1659, "contextual", "compact", "fixed"],
[1660, "wide", "expanded", "fixed"],
[1920, "wide", "expanded", "fixed"],
] as const;
const preference = Object.freeze({ collapsed: false, companyHidden: false });
for (const [width, capacity, globalMode, companyMode] of cases) {
assert.equal(workspaceCapacity(width), capacity);
assert.deepEqual(workspaceNavigationModes(capacity, preference), {
globalMode,
companyMode,
canFixCompany: width >= 1160,
});
}
assert.equal(
workspaceNavigationModes("wide", { collapsed: true, companyHidden: false }).globalMode,
"compact"
);
assert.equal(
workspaceNavigationModes("contextual", { collapsed: false, companyHidden: true }).companyMode,
"overlay"
);
assert.equal(
workspaceNavigationModes("wide", { collapsed: false, companyHidden: true }).companyMode,
"overlay"
);
assert.equal(preference.collapsed, false);
});
test("un menú solo libera su modalidad si su presentación pasa a estar disponible fija", () => {
const overlay = { capacity: "overlay", globalMode: "overlay", companyMode: "overlay" } as const;
const rail = { capacity: "rail", globalMode: "compact", companyMode: "overlay" } as const;
const fixed = { capacity: "contextual", globalMode: "compact", companyMode: "fixed" } as const;
assert.equal(workspacePanelBecomesFixed("global", overlay, rail), true);
assert.equal(workspacePanelBecomesFixed("global", rail, fixed), false);
assert.equal(workspacePanelBecomesFixed("company", rail, fixed), true);
assert.equal(workspacePanelBecomesFixed("company", fixed, rail), false);
assert.equal(
workspacePanelBecomesFixed("search", { ...overlay, capacity: "mobile" }, overlay),
true
);
assert.equal(workspacePanelBecomesFixed("search", overlay, rail), false);
});

View File

@ -36,98 +36,89 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
return (
<WorkspaceMobileNavigationProvider>
<WorkspaceSkipLink />
<WorkspaceSidebar
canManageUsers={canManageUsers}
catalog={catalog}
companies={companies}
current={current}
onSettings={() => setSettingsOpen(true)}
usersActive={Boolean(administrationContent)}
/>
<div
className="flex h-dvh min-h-0 flex-col overflow-hidden bg-shell-exterior shell:h-dvh shell:min-h-0 shell:flex-row shell:rounded-shell"
data-slot="workspace"
className="min-h-0 min-w-0 flex-1 overflow-y-auto bg-shell-content"
data-slot="workspace-scroll"
>
<WorkspaceSkipLink />
<WorkspaceSidebar
canManageUsers={canManageUsers}
catalog={catalog}
<WorkspaceHeader
accountNavigation={accountNavigation}
companies={companies}
current={current}
onSettings={() => setSettingsOpen(true)}
usersActive={Boolean(administrationContent)}
languageSwitcher={languageSwitcher}
userName={userName}
/>
<div
className="min-h-0 min-w-0 flex-1 overflow-y-auto bg-shell-content"
data-slot="workspace-scroll"
>
<WorkspaceHeader
accountNavigation={accountNavigation}
companies={companies}
languageSwitcher={languageSwitcher}
userName={userName}
/>
<WorkspaceSettingsDialog
accountNavigation={accountNavigation}
languageSwitcher={languageSwitcher}
onOpenChange={setSettingsOpen}
open={settingsOpen}
/>
<span className="sr-only">{coreTranslation("workspace")}</span>
{!administrationContent && query.isPending && (
<p className="p-6" role="status">
{t("loading")}
</p>
)}
{!administrationContent && query.isError && (
<div className="space-y-4 p-6">
<CompanyFeedback error={query.error} />
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{administrationContent ? (
<main
className="min-w-0 p-4 shell:p-6"
data-workspace-main=""
id="workspace-main"
tabIndex={-1}
<WorkspaceSettingsDialog
accountNavigation={accountNavigation}
languageSwitcher={languageSwitcher}
onOpenChange={setSettingsOpen}
open={settingsOpen}
/>
<span className="sr-only">{coreTranslation("workspace")}</span>
{!administrationContent && query.isPending && (
<p className="p-6" role="status">
{t("loading")}
</p>
)}
{!administrationContent && query.isError && (
<div className="space-y-4 p-6">
<CompanyFeedback error={query.error} />
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{administrationContent}
</main>
) : current.area === "company" ? (
<CompanyDetail
embedded
id={current.id}
initialConfiguration={current.configuration}
{t("retry")}
</Button>
</div>
)}
{administrationContent ? (
<main
className="min-w-0 p-4 shell:p-6"
data-workspace-main=""
id="workspace-main"
tabIndex={-1}
>
{administrationContent}
</main>
) : current.area === "company" ? (
<CompanyDetail
embedded
id={current.id}
initialConfiguration={current.configuration}
initialCreate={initialCreate}
initialEntityId={current.entityId}
key={current.id}
onBack={() => setLocation({ area: "directory" })}
onSectionChange={(section) => setLocation({ area: "company", id: current.id, section })}
section={current.section}
/>
) : (
<main
className="min-w-0 p-4 shell:p-6"
data-workspace-main=""
id="workspace-main"
tabIndex={-1}
>
<WorkspaceContent
area={current.area}
catalog={catalog}
initialCreate={initialCreate}
initialEntityId={current.entityId}
key={current.id}
onBack={() => setLocation({ area: "directory" })}
onSectionChange={(section) =>
setLocation({ area: "company", id: current.id, section })
}
section={current.section}
key={current.area === "catalogs" ? `catalogs:${catalog ?? "overview"}` : current.area}
navigation={current}
onNavigate={setLocation}
onSelectCompany={selectCompany}
/>
) : (
<main
className="min-w-0 p-4 shell:p-6"
data-workspace-main=""
id="workspace-main"
tabIndex={-1}
>
<WorkspaceContent
area={current.area}
catalog={catalog}
initialCreate={initialCreate}
key={
current.area === "catalogs" ? `catalogs:${catalog ?? "overview"}` : current.area
}
navigation={current}
onNavigate={setLocation}
onSelectCompany={selectCompany}
/>
</main>
)}
</div>
</main>
)}
</div>
</WorkspaceMobileNavigationProvider>
);

View File

@ -26,13 +26,14 @@ export function WorkspaceHeader({
const { t: coreTranslation } = useTranslation("core");
return (
<header className="sticky top-0 z-30 flex min-h-14 items-center gap-2 shell:static border-b border-shell-border bg-shell-header px-3 py-1.5 text-shell-navy shell:min-h-shell-header shell:gap-7 shell:py-[15px] shell:pl-[17px] shell:pr-[30px]">
<header className="sticky top-0 z-30 flex min-h-14 items-center gap-2 border-b border-shell-border bg-shell-header px-3 py-1.5 text-shell-navy shell:min-h-shell-header shell:gap-7 shell:py-[15px] shell:pl-[17px] shell:pr-[30px]">
<div className="flex min-w-0 flex-1 items-center gap-2 shell:hidden">
<Button
aria-controls={navigation?.contentId}
aria-expanded={navigation?.open && navigation.panel === "global"}
aria-label={t("mobileNavigation.global")}
className="size-11 shrink-0"
data-workspace-navigation-trigger="global"
onClick={(event) => navigation?.show("global", event.currentTarget)}
size="icon"
variant="ghost"
@ -42,6 +43,7 @@ export function WorkspaceHeader({
<BrowserLink
aria-label="Metis Desk"
className="flex min-w-0 items-center gap-2 [--primary:var(--color-shell-navy)]"
data-workspace-navigation-origin="global"
href="/dashboard"
>
<MetisLogo className="h-7 w-8 shrink-0 text-shell-sidebar" />
@ -52,6 +54,7 @@ export function WorkspaceHeader({
aria-expanded={navigation?.open && navigation.panel === "search"}
aria-label={t("mobileNavigation.search")}
className="ml-auto size-11 shrink-0"
data-workspace-navigation-trigger="search"
onClick={(event) => navigation?.show("search", event.currentTarget)}
size="icon"
variant="ghost"
@ -59,6 +62,20 @@ export function WorkspaceHeader({
<SearchIcon aria-hidden="true" />
</Button>
</div>
{navigation?.globalMode === "overlay" && (
<Button
aria-controls={navigation.contentId}
aria-expanded={navigation.open && navigation.panel === "global"}
aria-label={t("mobileNavigation.global")}
className="hidden size-11 shrink-0 shell:inline-flex"
data-workspace-navigation-trigger="global"
onClick={(event) => navigation.show("global", event.currentTarget)}
size="icon"
variant="ghost"
>
<MenuIcon aria-hidden="true" />
</Button>
)}
<WorkspaceSearch companies={companies} />
<span
aria-label={coreTranslation("shell.notificationsUnavailable")}

View File

@ -8,6 +8,7 @@ import {
KeyRoundIcon,
LayersIcon,
LayoutTemplateIcon,
MenuIcon,
SettingsIcon,
UsersIcon,
} from "@repo/rdx-ui/lib/icons";
@ -57,6 +58,21 @@ export function WorkspaceNavigationContent({
<MetisLogo className="h-[30px] w-8 shrink-0 text-shell-accent" />
<span className="shell:group-data-[collapsed=true]/sidebar:hidden">Metis Desk</span>
</div>
{collapsed && onToggleCollapsed && (
<div className="px-3 pt-3">
<WorkspaceNavigationButton
aria-controls={navigation?.contentId}
aria-expanded={navigation?.open && navigation.panel === "global"}
aria-label={t("mobileNavigation.global")}
data-workspace-navigation-trigger="global"
onClick={(event) => navigation?.show("global", event.currentTarget)}
title={t("mobileNavigation.global")}
>
<MenuIcon aria-hidden="true" />
<span>{t("mobileNavigation.global")}</span>
</WorkspaceNavigationButton>
</div>
)}
<nav
aria-label={t("companySpaces")}
className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto px-4 pb-4 shell:flex-1 shell:flex-col shell:flex-nowrap shell:gap-0.5 shell:overflow-y-auto shell:px-shell-sidebar-inset shell:pb-6 shell:pt-7 shell:group-data-[collapsed=true]/sidebar:px-3 shell:group-data-[collapsed=true]/sidebar:pb-3"

View File

@ -15,7 +15,7 @@ export function WorkspaceSearch({
return (
<>
<div className="hidden min-w-0 flex-1 shell:block">
<div className="hidden min-w-0 flex-1 shell:block" data-workspace-inline-search="">
<WorkspaceSearchContent
companies={companies}
presentation="inline"

View File

@ -1,28 +1,31 @@
import { useState } from "react";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { WorkspaceMobileSheet } from "../../shared/ui/workspace-mobile-sheet";
import { readSidebarCollapsed, saveSidebarCollapsed } from "../helpers/sidebar-preference";
import { WorkspaceMobileNavigationContext } from "../../shared/workspace-mobile-navigation-context";
import type { WorkspaceNavigationContentProps } from "./workspace-navigation-content";
import { WorkspaceNavigationContent } from "./workspace-navigation-content";
export function WorkspaceSidebar(props: WorkspaceNavigationContentProps) {
const [collapsed, setCollapsed] = useState(readSidebarCollapsed);
const navigation = useContext(WorkspaceMobileNavigationContext);
const collapsed = navigation?.globalMode === "compact";
const { t } = useTranslation("companies");
function toggleCollapsed() {
const nextCollapsed = !collapsed;
setCollapsed(nextCollapsed);
saveSidebarCollapsed(nextCollapsed);
if (navigation?.capacity === "wide") {
navigation.toggleCollapsed();
} else {
navigation?.show("global");
}
}
return (
<>
<aside
className="group/sidebar hidden min-h-0 w-full grow-0 shrink-0 basis-auto flex-col overflow-hidden bg-[linear-gradient(180deg,var(--color-shell-sidebar-top),var(--color-shell-sidebar)_45%,var(--color-shell-sidebar-bottom))] text-shell-sidebar-foreground shell:flex shell:w-shell-sidebar shell:basis-shell-sidebar shell:data-[collapsed=true]:w-shell-sidebar-collapsed shell:data-[collapsed=true]:basis-shell-sidebar-collapsed"
className="workspace-global-navigation group/sidebar flex min-h-0 w-full grow-0 shrink-0 basis-auto flex-col overflow-hidden bg-[linear-gradient(180deg,var(--color-shell-sidebar-top),var(--color-shell-sidebar)_45%,var(--color-shell-sidebar-bottom))] text-shell-sidebar-foreground shell:w-shell-sidebar shell:basis-shell-sidebar shell:data-[collapsed=true]:w-shell-sidebar-collapsed shell:data-[collapsed=true]:basis-shell-sidebar-collapsed"
data-collapsed={collapsed}
data-mode={navigation?.globalMode ?? "expanded"}
data-workspace-desktop-navigation=""
>
<WorkspaceNavigationContent

View File

@ -64,6 +64,10 @@ class VisualUiTests(unittest.TestCase):
def code(cls, body):
return AUDIT["result_json"](cls.tool("browser_run_code_unsafe", {"code": "async (page) => {" + body + "}"}))
@classmethod
def evaluate(cls, expression):
return cls.code("return await page.evaluate(()=> (" + expression + "));")
@classmethod
def record(cls, name, value):
cls.results.append({"scenario": name, "value": value})
@ -83,7 +87,7 @@ class VisualUiTests(unittest.TestCase):
self.settle()
def settle(self):
self.code("await page.evaluate(async()=>{await document.fonts.ready; await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))}); return true;")
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))}); return true;")
def test_selected_row_contrast(self):
self.resize(1440, 900)
@ -202,32 +206,30 @@ class VisualUiTests(unittest.TestCase):
def test_mobile_navigation_geometry(self):
baseline_path = ROOT / ".artifacts/playwright/v01-1/before/measurements.json"
baseline = json.loads(baseline_path.read_text()) if baseline_path.exists() else []
for width, height in [(320,640),(390,844),(768,1024),(1024,768),(1366,768),(1440,900),(1920,1080)]:
sizes=[(1920,1080),(1661,900),(1660,900),(1659,900),(1440,900),(1366,768),(1161,800),(1160,800),(1159,800),(1100,800),(1024,768),(921,768),(920,768),(919,768),(768,1024),(390,844),(320,640)]
for width,height in sizes:
self.resize(width,height)
self.navigate("assets")
self.code("await page.getByRole('button',{name:'Nuevo activo',exact:true}).waitFor();return true;")
metrics = self.code("""return await page.evaluate(()=>{
self.settle()
metrics=self.code("""return await page.evaluate(()=>{
const r=e=>{const b=e.getBoundingClientRect();return {x:b.x,y:b.y,width:b.width,height:b.height}};
return {main:r(document.querySelector('main')),header:r(document.querySelector('header')),global:r(document.querySelector('[data-workspace-desktop-navigation]')),context:r(document.querySelector('[data-slot=company-context-bar]')),overflow:document.documentElement.scrollWidth>innerWidth,dialog:document.querySelectorAll('[role=dialog]').length,visibleSections:[...document.querySelectorAll('nav[aria-label="Información de la compañía"] a')].filter(e=>e.getClientRects().length>0).length};
const root=document.querySelector('[data-slot=workspace]');const visible=e=>e.getClientRects().length>0;
return {main:r(document.querySelector('main')),header:r(document.querySelector('header')),global:r(document.querySelector('[data-workspace-desktop-navigation]')),context:r(document.querySelector('[data-slot=company-context-bar]')),globalMode:root.dataset.globalNavigation,companyMode:root.dataset.companyNavigation,capacity:root.dataset.capacity,overflow:document.documentElement.scrollWidth>innerWidth,dialog:document.querySelectorAll('[role=dialog]').length,visibleSections:[...document.querySelectorAll('nav[aria-label="Información de la compañía"] a')].filter(visible).length,preference:localStorage.getItem('metis-desk:sidebar-collapsed')};
});""")
self.record("navigation-geometry-"+str(width),metrics)
self.capture("navigation-assets-"+str(width))
self.assertFalse(metrics["overflow"])
self.assertEqual(metrics["dialog"],0)
self.assertFalse(metrics['overflow'])
self.assertEqual(metrics['dialog'],0)
self.assertEqual(metrics['globalMode'],'expanded' if width>=1660 else 'compact' if width>=920 else 'overlay')
self.assertEqual(metrics['companyMode'],'fixed' if width>=1160 else 'overlay')
self.assertEqual(metrics['visibleSections'],21 if width>=1160 else 0)
self.assertEqual(metrics['global']['width'],228 if width>=1660 else 72 if width>=920 else 0)
self.assertEqual(metrics['main']['width'],width-(228 if width>=1660 else 72 if width>=920 else 0)-(240 if width>=1160 else 0))
if width<768:
self.assertLessEqual(metrics["main"]["y"],128)
self.assertEqual(metrics["global"]["width"],0)
self.assertEqual(metrics["visibleSections"],0)
else:
self.assertEqual(metrics["global"]["width"],228)
self.assertEqual(metrics["context"]["width"],0)
self.assertEqual(metrics["visibleSections"],21)
old=next((x["metrics"]["main"] for x in baseline if x["screen"]=="assets" and x["width"]==width),None)
if old:
self.assertAlmostEqual(metrics["main"]["width"],old["w"],delta=1)
self.assertAlmostEqual(metrics["main"]["y"],old["y"],delta=1)
self.assertLessEqual(metrics['main']['y'],128)
if width==768:
self.assertEqual(metrics['main']['width'],768)
def test_mobile_navigation_keyboard_and_routes(self):
self.resize(390,844)
@ -300,6 +302,8 @@ class VisualUiTests(unittest.TestCase):
self.navigate("assets")
self.code("await page.getByRole('button',{name:'Menú global',exact:true}).click();return true;")
self.resize(768,1024)
self.assertTrue(self.code("return await page.getByRole('dialog').isVisible();"))
self.resize(920,768)
self.code("await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.assertTrue(self.code("return await page.evaluate(()=>document.activeElement!==document.body&&document.activeElement.getClientRects().length>0);"))
self.resize(390,844)
@ -332,6 +336,14 @@ class VisualUiTests(unittest.TestCase):
self.assertLessEqual(metrics['mainY'],128)
self.assertFalse(metrics['overflow'])
self.capture('navigation-long-name-320')
for width in [768,1024,1366]:
self.resize(width,768)
self.assertFalse(self.evaluate("document.documentElement.scrollWidth>innerWidth"))
self.capture('navigation-long-name-'+str(width))
if width>=1160:
self.code("await page.locator('[data-company-fixed-navigation] nav a').last().focus();return true;")
self.assertLessEqual(self.evaluate("document.activeElement.getBoundingClientRect().bottom"),768)
self.resize(320,640)
self.code("await page.getByRole('button',{name:"+json.dumps('Secciones de '+long_name)+",exact:true}).click();return true;")
self.resize(320,360)
self.code("await page.getByRole('dialog').locator('nav a').last().focus();return true;")
@ -370,5 +382,152 @@ class VisualUiTests(unittest.TestCase):
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});await page.locator('summary').click();await page.getByRole('combobox',{name:'Language'}).click();await page.getByRole('option',{name:'Español',exact:true}).click();await page.locator('summary').click();return true;")
def test_responsive_preferences_and_expansion(self):
self.resize(1920,1080)
self.navigate('assets')
self.code("await page.evaluate(()=>localStorage.removeItem('metis-desk:sidebar-collapsed'));await page.reload();await page.getByRole('button',{name:'Nuevo activo',exact:true}).waitFor();return true;")
try:
self.assertEqual(self.evaluate("document.querySelector('[data-slot=workspace]').dataset.globalNavigation"),'expanded')
self.resize(1440,900)
self.assertEqual(self.evaluate("document.querySelector('[data-slot=workspace]').dataset.globalNavigation"),'compact')
self.assertIsNone(self.evaluate("localStorage.getItem('metis-desk:sidebar-collapsed')"))
self.code("await page.getByRole('button',{name:'Expandir menú',exact:true}).click();await page.getByRole('dialog').waitFor();return true;")
self.capture('responsive-expanded-overlay-1440')
self.assertIsNone(self.evaluate("localStorage.getItem('metis-desk:sidebar-collapsed')"))
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});await page.waitForFunction(()=>document.activeElement.getAttribute('aria-label')==='Expandir menú');return true;")
self.resize(1920,1080)
self.code("await page.getByRole('button',{name:'Contraer menú',exact:true}).click();return true;")
self.assertEqual(self.evaluate("localStorage.getItem('metis-desk:sidebar-collapsed')"),'true')
self.resize(768,1024)
self.resize(1920,1080)
self.assertEqual(self.evaluate("document.querySelector('[data-slot=workspace]').dataset.globalNavigation"),'compact')
self.code("await page.reload();await page.getByRole('button',{name:'Expandir menú',exact:true}).waitFor();return true;")
self.assertEqual(self.evaluate("document.querySelector('[data-slot=workspace]').dataset.globalNavigation"),'compact')
self.code("await page.getByRole('button',{name:'Expandir menú',exact:true}).click();return true;")
self.assertEqual(self.evaluate("localStorage.getItem('metis-desk:sidebar-collapsed')"),'false')
self.record('responsive-preference-restored',self.evaluate("{mode:document.querySelector('[data-slot=workspace]').dataset.globalNavigation,stored:localStorage.getItem('metis-desk:sidebar-collapsed')}"))
finally:
self.code("await page.evaluate(()=>localStorage.removeItem('metis-desk:sidebar-collapsed'));await page.reload();return true;")
def test_responsive_context_intention_and_content(self):
self.resize(1366,768)
self.navigate('assets')
self.code("await page.getByRole('textbox',{name:'Buscar',exact:true}).fill('AUDIT');await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).waitFor();await page.evaluate(()=>window.auditTable=document.querySelector('main table'));await page.getByRole('button',{name:'Ocultar secciones',exact:true}).click();await page.getByRole('button',{name:/^Secciones de /}).waitFor();return true;")
self.assertTrue(self.evaluate("document.querySelector('main table')===window.auditTable"))
self.assertEqual(self.code("return await page.getByRole('textbox',{name:'Buscar',exact:true}).inputValue();"),'AUDIT')
self.capture('responsive-context-hidden-1366')
self.resize(1024,768)
self.resize(1366,768)
self.assertEqual(self.evaluate("document.querySelector('[data-slot=workspace]').dataset.companyNavigation"),'overlay')
self.code("await page.getByRole('button',{name:/^Secciones de /}).click();await page.getByRole('dialog').getByRole('button',{name:'Fijar secciones',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});await page.waitForFunction(()=>!!document.activeElement.closest('[data-company-fixed-navigation]'));return true;")
self.assertTrue(self.evaluate("document.querySelector('main table')===window.auditTable"))
self.assertEqual(self.evaluate("document.querySelector('[data-slot=workspace]').dataset.companyNavigation"),'fixed')
self.code("await page.getByRole('textbox',{name:'Buscar',exact:true}).fill('');return true;")
self.resize(1024,768)
self.code("await page.getByRole('button',{name:/^Secciones de /}).click();return true;")
self.assertTrue(self.code("return await page.getByRole('dialog').getByRole('button',{name:'Fijar secciones',exact:true}).isDisabled();"))
self.capture('responsive-context-overlay-1024')
self.code("await page.keyboard.press('Escape');return true;")
def test_responsive_resize_focus_and_modality(self):
self.resize(1024,768)
self.navigate('assets')
self.code("await page.getByRole('button',{name:/^Secciones de /}).click();await page.getByRole('dialog').getByRole('link',{name:/^Personas/}).focus();return true;")
self.resize(1366,768)
self.code("await page.getByRole('dialog').waitFor({state:'hidden'});await page.waitForFunction(()=>document.activeElement.getAttribute('href')?.endsWith('/people'));return true;")
self.assertTrue(self.evaluate("!!document.activeElement.closest('[data-company-fixed-navigation]')"))
self.resize(1024,768)
self.code("await page.waitForFunction(()=>document.activeElement.dataset.workspaceNavigationTrigger==='company');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),0)
self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).focus();return true;")
self.resize(768,1024)
self.assertEqual(self.evaluate("document.activeElement.textContent"),'AUDIT-PORT-01')
self.code("await page.getByRole('button',{name:'Menú global',exact:true}).click();await page.getByRole('dialog').getByRole('link',{name:'Compañías',exact:true}).focus();return true;")
self.resize(1024,768)
self.code("await page.getByRole('dialog').waitFor({state:'hidden'});await page.waitForFunction(()=>document.activeElement.getAttribute('href')==='/companies');return true;")
self.resize(768,1024)
self.code("await page.waitForFunction(()=>document.activeElement.dataset.workspaceNavigationTrigger==='global');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),0)
self.resize(1920,1080)
self.code("await page.locator('[data-workspace-desktop-navigation] a[href=\"/companies\"]').focus();return true;")
self.resize(1440,900)
self.assertEqual(self.evaluate("document.activeElement.getAttribute('href')"),'/companies')
self.record('responsive-resize-final-focus',self.evaluate("{tag:document.activeElement.tagName,href:document.activeElement.getAttribute('href'),visible:document.activeElement.getClientRects().length>0,dialogs:document.querySelectorAll('[role=dialog]').length,bodyOverflow:getComputedStyle(document.body).overflow}"))
self.capture('responsive-resize-final-1440')
def test_responsive_scroll_tooltips_and_form_stability(self):
self.resize(1366,768)
self.navigate('assets')
self.code("await page.keyboard.press('Tab');await page.locator('[data-workspace-desktop-navigation] a[href=\"/companies\"]').focus();await page.getByRole('tooltip').filter({hasText:'Compañías'}).waitFor();return true;")
self.capture('responsive-tooltip-focus-1366')
self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).focus();await page.mouse.move(500,600);await page.locator('[data-workspace-desktop-navigation] a[href=\"/companies\"]').hover();await page.getByRole('tooltip').filter({hasText:'Compañías'}).waitFor();return true;")
self.capture('responsive-tooltip-hover-1366')
self.code("await page.mouse.move(500,600);await page.locator('[data-company-fixed-navigation] nav a').last().focus();return true;")
scroll=self.evaluate("{nav:document.querySelector('[data-company-navigation-scroll]').scrollTop,main:document.querySelector('[data-slot=workspace-scroll]').scrollTop,bottom:document.activeElement.getBoundingClientRect().bottom}")
self.record('responsive-independent-scroll',scroll)
self.assertGreater(scroll['nav'],0)
self.assertEqual(scroll['main'],0)
self.assertLessEqual(scroll['bottom'],768)
self.capture('responsive-last-destination-1366')
self.navigate('assets')
self.code("await page.getByRole('button',{name:'Nuevo activo',exact:true}).click();await page.getByRole('dialog').waitFor();await page.evaluate(()=>window.auditForm=document.querySelector('[role=dialog]'));return true;")
self.resize(768,1024)
self.assertTrue(self.evaluate("document.querySelector('[role=dialog]')===window.auditForm&&!!document.activeElement.closest('[role=dialog]')"))
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.resize(1024,768)
self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).click();await page.locator('main h1').filter({hasText:'AUDIT-PORT-01'}).waitFor();await page.locator('[data-slot=workspace-scroll]').evaluate(e=>e.scrollTop=700);return true;")
self.settle()
self.resize(1366,768)
self.code("await page.locator('main button').first().focus();return true;")
self.record('responsive-sticky-1366',self.evaluate("{header:document.querySelector('header').getBoundingClientRect().top,nav:document.querySelector('[data-company-fixed-navigation]').getBoundingClientRect().top,focused:document.activeElement.getBoundingClientRect().top,overflow:document.documentElement.scrollWidth>innerWidth}"))
self.assertGreaterEqual(self.evaluate("document.activeElement.getBoundingClientRect().top"),88)
self.capture('responsive-detail-scrolled-1366')
def test_responsive_container_capacity(self):
self.resize(1920,1080)
self.navigate('assets')
self.code("await page.getByRole('button',{name:'Nuevo activo',exact:true}).waitFor();await page.evaluate(()=>{window.auditContainerTable=document.querySelector('main table');document.querySelector('[data-slot=workspace]').style.width='1024px'});await page.waitForFunction(()=>document.querySelector('[data-slot=workspace]').dataset.capacity==='rail');return true;")
try:
metrics=self.evaluate("({viewport:innerWidth,shell:document.querySelector('[data-slot=workspace]').getBoundingClientRect().width,global:document.querySelector('[data-slot=workspace]').dataset.globalNavigation,context:document.querySelector('[data-slot=workspace]').dataset.companyNavigation,sameTable:document.querySelector('main table')===window.auditContainerTable})")
self.record('responsive-contained-shell',metrics)
self.assertEqual(metrics,{'viewport':1920,'shell':1024,'global':'compact','context':'overlay','sameTable':True})
self.capture('responsive-contained-shell-1024')
finally:
self.code("await page.evaluate(()=>document.querySelector('[data-slot=workspace]').style.width='');await page.waitForFunction(()=>document.querySelector('[data-slot=workspace]').dataset.capacity==='wide');return true;")
def test_responsive_header_focus(self):
for start,end,panel,label in [(390,768,'global','Menú global'),(390,768,'search','Buscar en Metis Desk'),(1024,1366,'company','Secciones de '),(768,1024,'global','Menú global')]:
self.resize(start,844)
self.navigate('assets')
if panel=='company':
self.code("await page.getByRole('button',{name:/^Secciones de /}).focus();return true;")
else:
self.code("await page.getByRole('button',{name:"+json.dumps(label)+",exact:true}).focus();return true;")
self.resize(end,900)
focus=self.evaluate("({tag:document.activeElement.tagName,label:document.activeElement.getAttribute('aria-label'),visible:document.activeElement.getClientRects().length>0,body:document.activeElement===document.body})")
self.record('responsive-header-focus-'+panel+'-'+str(start)+'-'+str(end),focus)
self.assertFalse(focus['body'],focus)
self.assertTrue(focus['visible'],focus)
def test_responsive_company_switching(self):
# Solo crea una compañía sintética en la BD desechable; no cambia usuarios ni datos reales.
name='Empresa sintética secundaria V01.2'
self.resize(1024,768)
self.tool('browser_navigate',{'url':self.origin+'/companies'})
self.code("await page.getByRole('button',{name:'Nueva compañía',exact:true}).waitFor();return true;")
present=self.code("return await page.locator('main').getByRole('link',{name:"+json.dumps(name)+",exact:true}).count();")
if not present:
self.code("await page.route('**/api/companies',r=>r.request().method()==='POST'?r.continue():r.fallback());await page.getByRole('button',{name:'Nueva compañía',exact:true}).click();await page.getByRole('dialog').getByRole('textbox',{name:/^Nombre/}).fill("+json.dumps(name)+");await page.getByRole('dialog').getByRole('button',{name:'Guardar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});await page.unroute('**/api/companies');return true;")
self.resize(768,1024)
self.code("await page.getByRole('button',{name:'Menú global',exact:true}).click();await page.getByRole('dialog').getByRole('link',{name:"+json.dumps(name)+",exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});await page.getByRole('button',{name:"+json.dumps('Secciones de '+name)+",exact:true}).waitFor();return true;")
second=self.code("return new URL(page.url()).pathname;")
self.assertNotEqual(second,self.company)
self.capture('responsive-second-company-768')
self.code("await page.getByRole('button',{name:"+json.dumps('Secciones de '+name)+",exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Cambiar compañía'}).click();await page.getByRole('dialog').getByRole('link',{name:'Auditoría visual — Datos sintéticos',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.assertEqual(self.code("return new URL(page.url()).pathname;"),self.company)
self.record('responsive-company-switch',{'second':second,'returnedTo':self.company})
if __name__ == "__main__":
unittest.main()