UI Optimista y Movimiento¶
Un storefront se siente lento mucho antes de serlo. Hacer clic en + sobre una fila del carrito y ver el número quieto durante 400 ms mientras viaja un request es la diferencia entre una interfaz que responde y una que espera — y ningún ajuste del servidor elimina el viaje de ida y vuelta.
MageObsidian Components lo resuelve al revés: aplica el cambio en pantalla de inmediato, envÃa el request y reconcilia cuando el servidor contesta. La capa de estado y el bus de eventos ya aportan todo lo que hace falta — una copia local reactiva, un snapshot al que volver y una fase de la que colgar el spinner.
El flag¶
mage_obsidian/storefront/optimistic_ui, por defecto Yes, con alcance por website y por store view. Apagado, cantidad y eliminación esperan al servidor antes de tocar la pantalla — las animaciones siguen corriendo, sólo que cuando llega la respuesta.
El flag llega al navegador como window.__MAGE_OBSIDIAN_UX__, emitido en lÃnea a través de SecureHtmlRenderer para que lleve el nonce de CSP, y se lee asÃ:
Agregar al carrito es optimista sin importar el flag. Que el badge suba es el acuse de recibo del clic, y no hay estado que corromper: el contador se proyecta hacia adelante y la recarga obligatoria de secciones lo reemplaza con el número del servidor un instante después.
Cómo funciona la reconciliación¶
Tres piezas del section store, todas en useCustomerData:
| Miembro | Para qué |
|---|---|
patch(name, partial) |
mezcla una sección parcial en el mapa reactivo |
snapshot() |
el mapa actual, para poder volver |
restore(snapshot) |
lo devuelve |
patch es sólo en memoria — nunca escribe en mage-cache-storage. El customer-data.js de Magento sigue siendo el dueño de la caché canónica, asà que una proyección que resulte equivocada muere con la página en vez de sobrevivirle en local storage.
De ahà salen dos flujos:
Agregar no necesita rollback. post() ya recarga las secciones cart y messages al terminar, y esa recarga es la reconciliación: sobrescribe la proyección con la verdad, haya funcionado el add o no.
Cantidad y eliminación en el minicart toman un snapshot primero, proyectan, y lo devuelven si el servidor rechaza:
La fila eliminada vuelve a entrar por la misma transición por la que se fue, y la advertencia dice por qué.
Contar unidades o lÃneas¶
Magento decide qué significa el badge del bag: checkout/cart_link/use_qty hace que summary_count sean unidades; apagado, cuenta lÃneas. La proyección tiene que coincidir, o agregar dos de algo mueve el badge en la cantidad equivocada y la recarga lo corrige a la vista.
Esa configuración viaja en el mismo global como summaryCountsQty, y por eso la proyección la lee en vez de suponerla.
Estado de carga¶
Ningún componente es dueño de un flag de carga. Tanto el botón como el badge derivan el suyo del bus:
El badge del bag gana un anillo fino girando alrededor del Ãcono y atenúa el número al 45% mientras haya algo de carrito en vuelo. Como el add es optimista, el número ya es el nuevo — el anillo dice "sincronizando", no "todavÃa no sé".
El botón conserva su etiqueta dentro de la caja y la esconde con visibility, y centra el spinner por encima en absoluto:
Cambiar la etiqueta por un spinner es la implementación obvia y la equivocada: el spinner es más bajo y más angosto que el texto, asà que el botón encoge en el momento en que lo tocas y todo a su alrededor se mueve. Conservar la etiqueta reserva exactamente la caja que ya tenÃa. Nada salta, y no hay que adivinar ningún min-width.
El spinner mide 1em, asà que escala con el botón en el que caiga.
Movimiento¶
Eliminar una fila¶
La fila se desliza a la derecha desvaneciéndose mientras las de abajo suben suave para cerrar el hueco:
El position: absolute de la fila que sale es todo el truco. El TransitionGroup de Vue calcula los desplazamientos FLIP de -move a partir de dónde aterrizan los elementos restantes; mientras la fila que se va siga ocupando su espacio, no aterrizan en ningún lado. Sacarla del flujo les permite subir de inmediato, y la transición anima la diferencia.
Sólo se animan transform y opacity — ambas compuestas, ninguna dispara layout. Colapsar la fila animando height harÃa lo mismo a la vista, al costo de un pase de layout por frame.
El contenedor con scroll necesita overflow-x: hidden
La fila que sale se traslada 24 px más allá del borde derecho de una lista que es overflow-y: auto. CSS resuelve el otro eje de un contenedor con scroll también como auto, asà que al drawer le sale una barra horizontal durante toda la eliminación. Fijar overflow-x: hidden en la lista lo arregla.
Cambiar la cantidad¶
Pasan tres cosas a la vez, y cada una dice algo distinto:
- El número cambia de inmediato. Los controles nunca se deshabilitan, asà que clicar
+tres veces seguidas funciona; los requests se encolan y gana el último. - El stepper da un salto (
scale(1.08), 250 ms) — el acuse de recibo de que el clic entró. - El precio de lÃnea se atenúa y pulsa mientras su request está en vuelo, y el subtotal destella en
--color-accent-softcuando su valor cambia de verdad.
El destello es un composable, asà que la página del bag y el checkout lo obtienen igual:
El shimmer va sólo sobre el precio, no sobre la fila. Atenuar la fila entera para decir "un número se está actualizando" se lee como "este Ãtem está deshabilitado".
La página del bag intercambia filas, no las re-renderiza¶
La página del bag se renderiza en el servidor, asà que se re-solicita a sà misma y reemplaza la región del carrito entera. Cada fila lleva su propio view-transition-name, y la fila que se va se desvanece y desliza mientras las de abajo cierran el hueco — la misma idea que en el cajón, pero a través del navegador en vez de Vue.
Las animaciones tienen que acotarse a las filas que realmente entran o salen:
:only-child es como un snapshot dice que no tiene contraparte — una fila que sólo sale no tiene -new, una que sólo llega no tiene -old. Una fila que sobrevive al intercambio tiene ambas, y debe quedarse en el cross-fade por defecto. Sin la guarda, un paso de cantidad desvanece la fila superviviente durante 140 ms mientras la reaparece desde los 60 ms: las dos curvas dejan la fila en torno al 40% de opacidad durante ~60 ms, y eso se lee como un parpadeo. Es la versión a nivel de fila del error que el flash evita — atenuar la fila entera para decir que cambió un número.
Reemplazar la región también destruye aquello que el visitante estaba operando, asà que el enhancer registra el control enfocado antes del intercambio y lo restaura después. Sin eso, la segunda pulsación de + cae sobre nada.
Vaciarse¶
Cuando sale la última fila, la lista hace cross-fade hacia el panel vacÃo en vez de colapsar a él — un <Transition mode="out-in"> alrededor de los dos estados.
Acciones destructivas¶
Los controles de eliminar usan --color-danger (#a4322b, un rojo mineral apagado que convive con la paleta OBSIDIAN), al 72% de opacidad, llegando a opacidad plena y scale(1.1) en hover. Es un token del tema, no una constante del minicart, asà que cualquier acción destructiva del storefront usa el mismo rojo.
El Ãcono sale del sprite compartido a través del componente Icon, nunca SVG en lÃnea — la misma fuente que usa hero_icon(), asà que un componente que hidrata coincide con su marcado del servidor.
Dónde aterriza el feedback¶
Una confirmación que tapa la navegación es peor que ninguna confirmación, asà que el storefront toma dos decisiones separadas: quién anuncia un resultado y dónde se coloca ese anuncio.
Agregar al bag abre el bag¶
Un alta correcta abre el cajón del minicart y reclama el aviso vÃa result.announced (ver Eventos del Storefront), asà que no se emite ningún toast de éxito. El bag es la confirmación: la lÃnea está ahÃ, el subtotal está ahÃ, y el checkout queda a un clic. La fila cuya cantidad creció se tiñe con --color-accent-soft durante 1.8s, siguiendo la cantidad y no la llegada de un id nuevo — volver a agregar algo que ya estaba en el bag hace crecer una lÃnea en vez de crear una.
Un alta rechazada nunca se reclama. Sigue llegando a un toast, con el texto propio de Magento, y el cajón se queda cerrado.
El cajón también se aparta cuando la página del bag está en pantalla — se estarÃa tapando a sà mismo. Ahà tampoco reclama nadie el aviso, asà que el alta llega a un toast. La detección usa el marcador raÃz de esa página, no la URL, de modo que códigos de tienda y sufijos dan igual.
El cajón mueve el foco, asà que un lector de pantalla escucha el diálogo. El alta en sà la anuncia por separado una live region que vive fuera del cajón y por lo tanto ya está en el DOM cuando llega el mensaje — una live region insertada ya rellena no se anuncia de forma confiable.
La pila de toasts está anclada abajo¶
Los toasts van abajo: abajo a la derecha desde sm, centrados por debajo. La esquina superior derecha es donde viven los controles de cuenta, búsqueda y bag, y un toast que aterriza ahà tapa justamente el control del que habla — lo que además incumple el SC 2.4.11 Focus Not Obscured de WCAG 2.2, porque un usuario de teclado puede enfocar un control sobre el que el toast está pintado.
Todo lo que esté anclado al borde inferior hay que despejarlo, y cada contribuyente declara su propia custom property:
| Propiedad | La declara | Significa |
|---|---|---|
--obsidian-bottom-inset |
el dock móvil del checkout | mobiliario que toda capa flotante debe despejar |
--obsidian-cookie-notice-height |
el banner de consentimiento, medido en runtime | el alto del banner mientras está en pantalla |
--obsidian-toast-inset |
cualquier módulo con cromo propio en la esquina inferior | despeje extra que sólo necesita la pila de toasts |
.toast-host suma las tres a su propio padding, más env(safe-area-inset-bottom). Un módulo que estacione algo en la esquina inferior declara --obsidian-toast-inset acotado a la página que realmente lo renderiza — :root:has(.mi-widget), no un :root pelado — para que las páginas sin él no paguen el hueco.
Dentro de la pila, la región assertive se renderiza última, más cerca de la esquina anclada, porque ahà es donde cae la vista.
Movimiento reducido¶
Cada transición y animación de este documento se apaga bajo prefers-reduced-motion: reduce, incluidos los spinners:
El resultado sigue siendo plenamente funcional: las filas desaparecen, los números cambian, los estados se siguen leyendo — simplemente llegan al instante. La UI optimista es por qué eso funciona, porque sin la animación no queda nada que esperar.
Notas Clave¶
- Los nombres de clase del movimiento se escriben como selectores CSS planos en
module.extend.css, no como utilidades de Tailwind. Nada los referencia estáticamente, asà que el tree-shaker los tirarÃa. - Ese archivo vive en
view/frontend/web/css/, no enview/frontend/web/— en cualquier otro lado el tema no lo construye. patchnunca persiste. La caché canónica de customer-data sigue siendo de Magento.- Proyecta sólo lo que el servidor pueda confirmar. El minicart proyecta
itemsysummary_count; no recalcula el subtotal, porque impuestos, descuentos y reglas de totales son una respuesta que da el servidor. - Optimista no significa silencioso. Una mutación rechazada siempre se revierte y lo dice.
Próximos Pasos¶
- Eventos del Storefront — la convención de la que se deriva el estado de carga.
- Gestión de Estado —
patch,snapshotyrestoreen contexto. - Rendimiento — el presupuesto dentro del que este movimiento tiene que vivir.