Rediseño de navegación: pestañas en la barra superior y un diálogo de Settings
El footer del dashboard de Greenhouse se había convertido en un cajón de sastre. Ocho controles en fila: la píldora de conteo de Vault, la píldora de conteo de Harvest, la ruta del vault, un botón Board, Switch vault, un enlace “Why these rules?”, y tres píldoras de idioma. Nada distinguía “ir a otra vista” de “cambiar una configuración”, todos estaban al mismo peso visual. Peor aún, una clave de i18n cumplía doble función: dashboard_vault_label renderizaba “Vault:” tanto como etiqueta de la píldora de conteo como prefijo de la fila de la ruta. En español eso es “Banco de semillas:” sentado justo antes de una ruta de archivos, lo cual se lee como algo sin sentido.
La solución separó esas dos preocupaciones: una fila de pestañas persistente en la barra superior para las cuatro vistas pares (Today/Board/Vault/Harvest, con insignias de conteo en Vault/Harvest), y un único diálogo de Settings con ícono de engranaje para todo lo demás (ruta del vault, Switch vault, idioma, Why these rules?). La fila de la ruta recibió su propia clave, dashboard_vault_path_label, así que por fin puede decir simplemente “Folder:” sin arrastrar consigo un sustantivo mal traducido.
Decisiones de diseño que valía la pena pausar a pensar
Surgieron dos cosas que no eran realmente decisiones de ingeniería, eran decisiones de “qué quiere ser esta app”, así que las tomé de forma deliberada en vez de en piloto automático. Punto de entrada de Settings: ¿solo el botón de engranaje, o también un ítem nativo “Preferences…” (Cmd+,) bajo el menú de la app? Se optó por ambos, es el mismo diálogo de cualquier forma, y Cmd+, es la memoria muscular nativa de macOS para esto. Botones de Back por vista: una vez que hay una fila de pestañas persistente, ¿las vistas de Vault/Harvest/Board todavía necesitan su propio botón de Back? Esto sí tiene una respuesta documentada: macOS distingue la navegación entre pares (cambiar mediante un selector persistente, sin botón de Back, de una pestaña no se “sale hacia atrás”) de la profundización jerárquica (Back es para bajar un nivel más, como el detalle de un proyecto desde una tarjeta de la lista de trabajo). Mantener botones de Back en las vistas pares habría sido la verdadera inconsistencia. Se quitaron, el detalle de proyecto conserva el suyo, porque de verdad es una profundización.
Lo que hubiera salido mal sin una segunda pasada
Mi primer borrador del plan quería colapsar las cuatro banderas booleanas de vista (vaultOpen, harvestOpen, boardOpen, selectedItem) en un solo enum currentView antes de tocar la interfaz, se sentía como la base “correcta” para una fila de pestañas. Una segunda mirada me disuadió: las banderas existentes ya tienen un helper probado de traspaso limpio (closeAllViews(), construido para el menú nativo View en un issue anterior), y una refactorización a enum habría significado tocar cada rama de un template de 280 líneas y reescribir los tests de enrutamiento del menú que ya pasaban. Me quedé con los booleanos y solo agregué un pequeño wrapper switchView() compartido entre la fila de pestañas y el manejador del menú. Diff más chico, mismo resultado, nada desestabilizado.
Dos trampas de ARIA y herramientas
El primer intento de la fila de pestañas usó role="tablist" porque, semánticamente, son pestañas de cambio de vista. El propio test automatizado de a11y del proyecto (axe, corrido contra un render real) lo detectó de inmediato: un rol tablist requiere hijos con rol tab y semántica de navegación por flechas de teclado que yo no había construido. Lo que estos elementos son en realidad es un grupo de botones de alternancia, exactamente lo que ya usaba el selector de idioma existente (role="group" + aria-pressed). Se ajustó a eso en vez de inventar un patrón ARIA a medio construir.
Segundo: verificar el diálogo de Settings contra la app real corriendo (a través de el harness de WebDriver) devolvió capturas de pantalla que parecían mostrar que el diálogo no estaba ahí, solo una sombra tenue en la parte de abajo del cuadro. Resulta que los elementos <dialog> nativos abiertos con showModal() se renderizan en la “capa superior” del navegador, y el endpoint de capturas no alcanza esa capa. El diálogo estaba realmente abierto y correctamente posicionado (confirmado vía getBoundingClientRect() y verificaciones de contenido de texto en vez de píxeles), la captura simplemente era ciega a él. Ya era una trampa conocida de una sesión anterior, pero es del tipo de cosa que se redescubre por las malas si no se revisa primero.
Resultado
158 tests de frontend en verde (32 de ellos solo en Dashboard.test.ts), 223 tests de Rust en verde, cargo clippy/fmt limpio, y un script de e2e dedicado maneja la app real a través de cada pestaña y cada subdiálogo de Settings antes de dar la tarea por terminada.
Lecturas relacionadas
Toast y deshacer para la acción de vault de Greenhouse
La primera superficie de feedback transitorio de la app, la regla de aria-live que hace que un toast renderizado condicionalmente quede sin anunciar en silencio, y un prop que queda desactualizado apenas se renombra algo.
Arreglar una carrera de concurrencia, un bug de la tecla Esc, y un crash por clave duplicada en Greenhouse
Un contador monotónico para refrescos fuera de orden, un evento de diálogo cancelable, una clave de timestamp que choca dentro del mismo segundo, y una prueba de regresión que no podía fallar.
Arreglar el error de manejo de foco de Greenhouse me enseñó la diferencia entre un diálogo y una página
El ticket decía que había que restaurar el foco al elemento que lo disparó. Ese elemento ya no existe, se destruyó en el instante en que se abrió la vista. El patrón correcto era el de una navegación de página, no el de un modal.