fix: two defects that only reproduce over a real network

Neither is visible against localhost, which is why both shipped.

**Install button never appeared on `/login`** — a regression from making the login a
lazy route. `beforeinstallprompt` fires once per page load and is never replayed;
`useInstallPrompt` attached its listener from a `useEffect`, i.e. at mount, and
`InstallAppPrompt` now mounts only after an extra round-trip for its chunk. Locally
that round-trip is a millisecond so the listener still won a race it should never
have been in. Over a real connection the event was long gone, so a user on a slow
link lost the install button entirely.

The listener now lives in `src/lib/installPrompt.ts` and registers when the module
evaluates — `main.tsx` imports it for its side effect before mounting React. The hook
only reads from that store. Reproduced deterministically by delaying
`/assets/LoginPage-*.js` by 1.5s via `route()`: absent before, present after (also at
a 4s delay). `test:pwa` against the HTTPS domain now passes.

**Revenue chart did not animate on a real iPhone.** Measured rather than guessed:
the path animation does run in WebKit, and it triggers with the chart 100% visible at
y=601..715 of an 844px viewport — so neither "broken" nor "fires too early". What
fits is that iOS Safari suspends `requestAnimationFrame` during momentum scrolling
while framer-motion interpolates against wall-clock time: the animation spends its
1.4s without painting a frame and snaps to the end on resume, which looks exactly
like it never ran.

The chart's three animations move to CSS keyframes, which keep their own timeline in
the engine. The component only decides *when* (a `useInView` setting
`data-ld-rev-visible`). `prefers-reduced-motion` resolves in CSS too, and still
resolves to the *drawn* state — a line left at `dashoffset: 1px` with no animation
would be invisible forever. Verified: `getAnimations()` returns a `CSSAnimation`, and
under reduced motion the line renders complete.

Not verified: no physical iPhone here, and Playwright WebKit on Windows does not
reproduce iOS's rAF suspension. This is the standard mitigation and changes nothing
on desktop, but on-device confirmation is still outstanding.

Verified: typecheck clean; landing 13/13, PWA passed, responsive 0 findings, visual
62 screens / 0 errors, unit 39/39, e2e 33/33, admin 17/17, booking 12/12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
AgendaPro Dev
2026-07-28 15:09:29 -06:00
co-authored by Claude Opus 5
parent 842a9ea07e
commit 4281567207
7 changed files with 317 additions and 52 deletions
@@ -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<SVGSVGElement>(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 (
<svg
ref={ref}
data-ld-rev-visible={visible ? "true" : "false"}
viewBox={`0 0 ${W} ${H}`}
className={`w-full ${className}`}
role="img"
@@ -57,36 +72,26 @@ export function RevenueLineVisual({ className = "" }: { className?: string }) {
/>
))}
<motion.path
d={area}
fill="url(#ld-rev-fill)"
initial={{ opacity: reduced ? 1 : 0 }}
whileInView={{ opacity: 1 }}
viewport={inView}
transition={{ duration: 0.9, delay: reduced ? 0 : 0.5, ease: EASE_EXPO }}
/>
<motion.path
<path className="ld-rev-area" d={area} fill="url(#ld-rev-fill)" />
{/* `pathLength={1}` normaliza la longitud: deja que dasharray y dashoffset se
expresen como fracción del trazo, que es lo que animan los keyframes. */}
<path
className="ld-rev-linea"
d={d}
pathLength={1}
fill="none"
stroke="#3b66ff"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduced ? 1 : 0 }}
whileInView={{ pathLength: 1 }}
viewport={inView}
transition={{ duration: reduced ? 0 : 1.4, ease: EASE_EXPO }}
/>
{/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */}
<motion.circle
<circle
className="ld-rev-punto"
cx={W - PAD}
cy={yDe(SERIE[SERIE.length - 1])}
r="5"
fill="#3b66ff"
initial={{ scale: reduced ? 1 : 0 }}
whileInView={{ scale: 1 }}
viewport={inView}
transition={{ duration: 0.5, delay: reduced ? 0 : 1.3, ease: EASE_EXPO }}
/>
</svg>
);