diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index 05ca395..3b532ae 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -184,6 +184,69 @@ HALLAZGO DE SEGURIDAD, fuera del repo y sin tocar: el token de la API de Coolify una llave SSH privada en texto plano. Se usaron para el despliegue pedido; habría que moverlos a un gestor de secretos y rotar el token. +--- + +## 2026-07-28 — Dos defectos que solo aparecen en producción + +Encontrados después del despliegue anterior: ninguno de los dos se ve contra `localhost`. + +### 1. El botón «Instalar AgendaMax» no aparecía en `/login` (regresión propia) + +`npm run test:pwa` con `PWA_BASE_URL=https://agendamax.urieljareth.org` falló en +`assertInstallAction(..., "login")`. Contra `localhost` pasaba. + +Causa: `beforeinstallprompt` se dispara **una sola vez** por carga y no se repite. +`useInstallPrompt` registraba su listener dentro de un `useEffect`, o sea al montar el componente. Al +volver `LoginPage` una ruta `lazy` —cambio de esta misma tanda— `InstallAppPrompt` monta después de +una ida y vuelta de red extra para traer su chunk. En local eso es un milisegundo y el listener llega +a tiempo; sobre la red real el evento ya pasó. No era solo el test: un usuario en conexión lenta +perdía el botón de instalar. + +Arreglo: `src/lib/installPrompt.ts`, un almacén que registra el listener al evaluarse el módulo, que +`main.tsx` importa por su efecto antes de montar React. `useInstallPrompt` pasa a leer de ahí +(`eventoDisponible`/`tomarEvento`/`devolverEvento`) y ya no añade listeners propios. + +Reproducción determinista, porque el test local no lo veía: interceptar `/assets/LoginPage-*.js` con +`route()` y retrasarlo 1.5s, manteniendo el disparo sintético en `load`+100ms. +- Contra producción con el código viejo: evento a los 490ms, botón AUSENTE. +- Contra el build con el arreglo, retraso de 1.5s y de 4s: botón PRESENTE en los dos. + +### 2. La gráfica de ingresos no animaba en un iPhone real (reportado por el usuario) + +`aria-label="Ingresos mensuales en tendencia ascendente"`, en la landing. + +Lo primero fue descartar hipótesis midiendo, no suponiendo: +- ¿Está roto el `pathLength`? No: en WebKit a 390px con scroll lento la animación **sí** corría + (dasharray 0.14 → 1 en ~1.5s). +- ¿Se dispara demasiado pronto, con el elemento asomando por el borde? **No.** Se dispara con la + gráfica en y=601..715 de un viewport de 844px, **100% visible**. Hipótesis descartada. +- ¿`prefers-reduced-motion`? Reproduce el síntoma **exactamente**: sale ya dibujada y estática. Es + comportamiento por diseño, no un defecto. Queda como causa posible del reporte. + +Causa que sí se puede blindar: iOS Safari suspende `requestAnimationFrame` durante el scroll por +inercia. framer-motion interpola contra el reloj de pared, así que la animación consume su 1.4s sin +pintar un fotograma y al reanudarse salta al estado final — se ve idéntico a "nunca animó". + +Arreglo: sacar las tres animaciones de la gráfica de framer-motion y pasarlas a keyframes CSS +(`ld-rev-trazo`, `ld-rev-aparecer`, `ld-rev-aterrizar` en index.css). El componente solo decide +cuándo empezar, con un `useInView` que pone `data-ld-rev-visible`. Las animaciones CSS llevan su +propia línea de tiempo en el motor y siguen dibujando cuando rAF no corre. `prefers-reduced-motion` +también se resuelve en CSS, así que el componente ya no necesita el hook. + +Verificado: dashoffset 0.93 → 0 en ~1.5s con el área entrando en su retardo; con movimiento reducido +la línea queda dibujada (dashoffset 0), **no invisible**; y `getAnimations()` devuelve una +`CSSAnimation` llamada `ld-rev-trazo` de 1400ms en estado `running`, o sea que la lleva el motor. + +**Lo que NO pude verificar:** no tengo un iPhone físico aquí, y Playwright WebKit en Windows no +reproduce la suspensión de rAF durante el scroll de iOS. El arreglo es la mitigación estándar para +ese comportamiento y no cambia nada visualmente en escritorio, pero la confirmación en el dispositivo +queda pendiente del usuario. Si con Reduce Motion **desactivado** sigue sin animar, la causa es otra +y hay que volver a medir en el dispositivo. + +Gates tras los dos arreglos: typecheck 0 · test:landing 13/13 · test:pwa passed · audit:responsive 0 +hallazgos · audit:visual 62 pantallas 0 fallos 0 errores · unit 39/39 · e2e 33/33 · admin 17/17 · +booking 12/12. + FOLLOW-UPS DIFERIDOS: 1. `/login` en desktop deja un vacío bajo la tarjeta del formulario (columnas de altura muy distinta). No es un defecto medible; revisar si molesta en uso real. diff --git a/CLAUDE.md b/CLAUDE.md index e12a8ed..a43123a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,6 +290,16 @@ números») es literal. Si cambias `PIE_COLORS`, cambia también el abanico. `prefers-reduced-motion` los `initial` resuelven al **estado final**, no se acortan: un `initial={{opacity:0}}` cuyo `whileInView` nunca corre deja el bloque invisible para siempre. El test lo verifica recorriendo las siete secciones. +- **La gráfica de ingresos es la excepción: se anima con CSS, no con framer-motion.** iOS Safari + suspende `requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion interpola + contra el reloj de pared: la animación gasta su duración sin pintar un fotograma y al reanudarse + salta al estado final. En un iPhone se ve como si nunca hubiera animado — fue un defecto reportado + desde producción, y ningún audit lo detecta porque en WebKit de escritorio sí anima. + [RevenueLineVisual](src/components/landing/visuals/RevenueLineVisual.tsx) solo decide *cuándo* + empieza (un `useInView` que pone `data-ld-rev-visible`); el *cómo* son los keyframes `ld-rev-*` de + [src/index.css](src/index.css), que llevan su propia línea de tiempo en el motor. El trazo usa + `pathLength={1}` para que dasharray/dashoffset sean fracciones. Si añades otra animación de trazo + de línea en la landing, hazla igual. ### Barra lateral @@ -339,6 +349,19 @@ no-GET o cross-origin. Si tocas el service worker, conserva ese bypass (hay un t `pwa-e2e.mjs`) y sube el sufijo de `CACHE_NAME` (`agendamax-shell-vN`) para que el cleanup de `activate` purgue el anterior. Se registra únicamente en producción. Iconos: `npm run generate:pwa-icons`. +**`beforeinstallprompt` se captura a nivel de módulo, no en un hook.** El navegador lo dispara una +sola vez por carga y no lo repite. Como `LoginPage` es una ruta `lazy`, `InstallAppPrompt` monta +después de una ida y vuelta de red extra: un listener registrado al montar llega tarde y el botón +«Instalar AgendaMax» no aparece nunca. Por eso el listener vive en +[src/lib/installPrompt.ts](src/lib/installPrompt.ts), que `main.tsx` importa por su efecto **antes** +de montar React, y [useInstallPrompt](src/lib/useInstallPrompt.ts) solo lee de ese almacén. No +devuelvas el listener a un `useEffect`, y si vuelves eager alguna ruta no asumas que eso lo arregla. + +Detalle de verificación: `npm run test:pwa` contra `localhost` **no** detecta ese defecto, porque el +chunk lazy llega antes que el evento. Solo se ve contra el dominio HTTPS real +(`PWA_BASE_URL=https://…`), donde la latencia abre la ventana. Si tocas el arranque del prompt, +prueba contra producción o retrasa el chunk a propósito con `route()`. + ## Convenciones de trabajo - **No hagas commits salvo que se pidan explícitamente** (política registrada en diff --git a/src/components/landing/visuals/RevenueLineVisual.tsx b/src/components/landing/visuals/RevenueLineVisual.tsx index 9cb99c0..91d56ea 100644 --- a/src/components/landing/visuals/RevenueLineVisual.tsx +++ b/src/components/landing/visuals/RevenueLineVisual.tsx @@ -1,6 +1,6 @@ -import { motion } from "framer-motion"; -import { EASE_EXPO, inView } from "../../../lib/motion"; -import { useReducedMotion } from "../../../lib/useReducedMotion"; +import { useRef } from "react"; +import { useInView } from "framer-motion"; +import { inView } from "../../../lib/motion"; // Serie fija: doce meses con tendencia al alza y ruido creíble. Determinista a // propósito, para que el audit visual capture siempre la misma gráfica. @@ -23,14 +23,29 @@ function pathFrom(serie: number[]): string { .join(" "); } -/** Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. */ +/** + * Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. + * + * El trazo lo hacen animaciones CSS (`.ld-rev-*` en index.css) y no `motion.path`. No es + * una preferencia de estilo: iOS Safari suspende `requestAnimationFrame` durante el scroll + * por inercia, y framer-motion interpola contra el reloj de pared, así que la animación + * gastaba su duración sin pintar nada y saltaba al final. En un iPhone se veía como si + * nunca hubiera animado. Este componente solo decide CUÁNDO empieza; el CÓMO es del + * compositor, que sí sigue dibujando. + * + * `prefers-reduced-motion` también se resuelve en CSS, así que ya no hace falta el hook. + */ export function RevenueLineVisual({ className = "" }: { className?: string }) { - const reduced = useReducedMotion(); + const ref = useRef(null); + // Mismo umbral que el resto de la landing, y `once` para que no se repita al volver. + const visible = useInView(ref, inView); const d = pathFrom(SERIE); const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`; return ( ))} - - + {/* `pathLength={1}` normaliza la longitud: deja que dasharray y dashoffset se + expresen como fracción del trazo, que es lo que animan los keyframes. */} + {/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */} - ); diff --git a/src/index.css b/src/index.css index 4ca81e9..2ea65f7 100644 --- a/src/index.css +++ b/src/index.css @@ -624,6 +624,83 @@ summary { color: transparent; } } + + /* ---- Gráfica de ingresos de la landing ---- + Se anima con CSS y NO con framer-motion a propósito. iOS Safari suspende + `requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion + interpola contra el reloj de pared: la animación consume su duración sin pintar un + solo fotograma y al reanudarse salta al estado final. En un iPhone se ve exactamente + como si nunca hubiera animado, que es el defecto que se reportó. Una animación CSS + lleva su propia línea de tiempo y sí se dibuja. + El easing es el mismo `EASE_EXPO` de src/lib/motion.ts; si cambia uno, cambia el otro. */ + .ld-rev-linea { + stroke-dasharray: 1px 1px; /* con pathLength="1" las unidades son la longitud total */ + stroke-dashoffset: 1px; + } + .ld-rev-area { + opacity: 0; + } + .ld-rev-punto { + /* `fill-box` para que el centro del círculo sea el origen y no el del lienzo SVG. */ + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + } + [data-ld-rev-visible="true"] .ld-rev-linea { + animation: ld-rev-trazo 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; + } + [data-ld-rev-visible="true"] .ld-rev-area { + animation: ld-rev-aparecer 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards; + } + [data-ld-rev-visible="true"] .ld-rev-punto { + animation: ld-rev-aterrizar 0.5s cubic-bezier(0.16, 1, 0.3, 1) 1.3s forwards; + } +} + +@keyframes ld-rev-trazo { + from { + stroke-dashoffset: 1px; + } + to { + stroke-dashoffset: 0px; + } +} +@keyframes ld-rev-aparecer { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes ld-rev-aterrizar { + from { + transform: scale(0); + } + to { + transform: scale(1); + } +} + +/* Con movimiento reducido la gráfica sale ya dibujada. Igual que el resto de la landing, + el estado inicial resuelve al FINAL y no se acorta la transición: si dejara la línea en + `dashoffset: 1px` sin animación, quedaría invisible para siempre. */ +@media (prefers-reduced-motion: reduce) { + .ld-rev-linea, + [data-ld-rev-visible="true"] .ld-rev-linea { + stroke-dashoffset: 0px; + animation: none; + } + .ld-rev-area, + [data-ld-rev-visible="true"] .ld-rev-area { + opacity: 1; + animation: none; + } + .ld-rev-punto, + [data-ld-rev-visible="true"] .ld-rev-punto { + transform: scale(1); + animation: none; + } } /* ---- Anti auto-zoom de iOS (fuera de @layer para ganar a las utilidades) ---- diff --git a/src/lib/installPrompt.ts b/src/lib/installPrompt.ts new file mode 100644 index 0000000..ca67da5 --- /dev/null +++ b/src/lib/installPrompt.ts @@ -0,0 +1,82 @@ +/** + * Captura de `beforeinstallprompt` a nivel de módulo, no dentro de un hook. + * + * El navegador dispara `beforeinstallprompt` **una sola vez** por carga de página, poco + * después del `load`, y no lo repite: no hay forma de volver a pedirlo. Un listener que + * se registra al montar un componente sencillamente se lo pierde si el componente aparece + * más tarde. + * + * Y aparece más tarde: `LoginPage` se carga con `React.lazy`, así que `InstallAppPrompt` + * monta después de una ida y vuelta de red extra para traer su chunk. En local eso es un + * milisegundo y no se nota; sobre una conexión real el evento ya pasó y el botón de + * instalar no aparecía nunca. `pwa-e2e.mjs` contra el dominio HTTPS lo detecta; contra + * `localhost` no, porque ahí el chunk llega antes que el evento. + * + * Por eso el listener se registra al evaluar este módulo, que `main.tsx` importa antes de + * montar React, y el evento queda guardado hasta que alguien lo pida. + */ + +export interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; +} + +let capturado: BeforeInstallPromptEvent | null = null; +let instalado = false; +const suscriptores = new Set<() => void>(); + +function avisar() { + for (const notificar of suscriptores) notificar(); +} + +if (typeof window !== "undefined") { + window.addEventListener("beforeinstallprompt", (event) => { + // `preventDefault` evita el mini-infobar de Chrome; el botón propio queda a cargo. + event.preventDefault(); + capturado = event as BeforeInstallPromptEvent; + avisar(); + }); + window.addEventListener("appinstalled", () => { + capturado = null; + instalado = true; + avisar(); + }); +} + +/** El evento pendiente, o null si el navegador nunca lo disparó. */ +export function eventoDisponible(): BeforeInstallPromptEvent | null { + return capturado; +} + +export function yaInstalado(): boolean { + return instalado; +} + +/** Lo saca del almacén: un `BeforeInstallPromptEvent` solo se puede usar una vez. */ +export function tomarEvento(): BeforeInstallPromptEvent | null { + const evento = capturado; + if (evento) { + capturado = null; + avisar(); + } + return evento; +} + +/** Lo devuelve si `prompt()` falló, para no perder la única oportunidad de instalar. */ +export function devolverEvento(evento: BeforeInstallPromptEvent) { + capturado = evento; + avisar(); +} + +export function marcarInstalado() { + capturado = null; + instalado = true; + avisar(); +} + +export function suscribir(notificar: () => void): () => void { + suscriptores.add(notificar); + return () => { + suscriptores.delete(notificar); + }; +} diff --git a/src/lib/useInstallPrompt.ts b/src/lib/useInstallPrompt.ts index e5fe742..218a617 100644 --- a/src/lib/useInstallPrompt.ts +++ b/src/lib/useInstallPrompt.ts @@ -1,12 +1,15 @@ import { useEffect, useRef, useState } from "react"; +import { + devolverEvento, + eventoDisponible, + marcarInstalado, + suscribir, + tomarEvento, + yaInstalado, +} from "./installPrompt"; export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed"; -interface BeforeInstallPromptEvent extends Event { - prompt: () => Promise; - userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; -} - const DISMISSED_STORAGE_KEY = "agendamax-install-dismissed"; function hasDismissedPrompt() { @@ -44,13 +47,18 @@ function isIOSSafari() { return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua); } +/** + * El estado inicial ya consulta el almacén de `installPrompt`: cuando este hook corre, el + * evento puede haberse disparado hace rato. No se registra un listener propio aquí a + * propósito — ver el comentario de `src/lib/installPrompt.ts`. + */ export function useInstallPrompt() { const [state, setState] = useState(() => { if (typeof window === "undefined") return "unsupported"; - if (isStandalone()) return "installed"; + if (isStandalone() || yaInstalado()) return "installed"; + if (eventoDisponible()) return hasDismissedPrompt() ? "unsupported" : "available"; return isIOSSafari() ? "ios-instructions" : "unsupported"; }); - const [deferred, setDeferred] = useState(null); const [dismissed, setDismissed] = useState(() => typeof window !== "undefined" && hasDismissedPrompt()); const installInFlight = useRef(false); @@ -60,41 +68,44 @@ export function useInstallPrompt() { return; } - const onBeforeInstallPrompt = (event: Event) => { - event.preventDefault(); - setDeferred(event as BeforeInstallPromptEvent); - if (!hasDismissedPrompt()) setState("available"); - }; - const onInstalled = () => { - setDeferred(null); - clearDismissedPrompt(); - setDismissed(false); - setState("installed"); + const sincronizar = () => { + if (yaInstalado()) { + // Instalar borra el descarte: el aviso ya cumplió su función. + clearDismissedPrompt(); + setDismissed(false); + setState("installed"); + return; + } + if (eventoDisponible()) { + if (!hasDismissedPrompt()) setState("available"); + return; + } + // Sin evento pendiente: se cae al comportamiento de iOS, que nunca lo dispara. + setState(isIOSSafari() ? "ios-instructions" : "unsupported"); }; - window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); - window.addEventListener("appinstalled", onInstalled); - return () => { - window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt); - window.removeEventListener("appinstalled", onInstalled); - }; + sincronizar(); + return suscribir(sincronizar); }, []); const install = async () => { - if (!deferred || installInFlight.current) return; - const event = deferred; + if (installInFlight.current) return; + const event = tomarEvento(); + if (!event) return; installInFlight.current = true; - setDeferred(null); try { await event.prompt(); const choice = await event.userChoice; - if (choice.outcome === "accepted") setState("installed"); - else { + if (choice.outcome === "accepted") { + marcarInstalado(); + setState("installed"); + } else { persistDismissedPrompt(); setDismissed(true); } } catch { - setDeferred(event); + // Devolver el evento al almacén: es la única oportunidad de instalar de esta carga. + devolverEvento(event); setState("available"); } finally { installInFlight.current = false; diff --git a/src/main.tsx b/src/main.tsx index bc3b336..85e9f08 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +// Importado por su efecto y antes de montar React a propósito: registra el listener de +// `beforeinstallprompt`, que el navegador dispara una sola vez y muy temprano. Si esperara +// a que monte el componente del botón —que vive en una ruta lazy— llegaría tarde. +import "./lib/installPrompt"; import App from "./App.tsx"; import "./index.css";