Files
AgendaPro/.superpowers/sdd/progress.md
T
AgendaPro DevandClaude Opus 5 4281567207 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]>
2026-07-28 15:09:29 -06:00

257 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SDD Progress Ledger — Auto-assign specialist + booking redesign
- **Plan:** `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md`
- **Spec:** `docs/superpowers/specs/2026-07-26-auto-assign-specialist-booking-redesign-design.md`
- **Branch:** `feat/auto-assign-specialist`
- **Base commit:** `e8d2435`
- **Commit policy:** NO commits during execution (repo policy: only on explicit request). Verify per task via typecheck + tests. Reviewer reads changed files + plan task.
- **Pre-existing WIP (do NOT clobber):** `src/components/AppointmentModal.tsx`, `src/pages/CalendarPage.tsx`, FullCalendar section of `src/index.css` (~lines 136-160). Use targeted `edit` only.
## Tasks
- Task 1: complete — db.ts (migrateV3ToV4 + runMigrations call), shared/types.ts (Business/Employee/WorkingHoursMap). Review: APPROVED, no findings. typecheck 0 errors, schema_version=4, backfill verified.
- Task 2: complete — server/lib/scheduling.ts (pure helpers, no imports/DB), server/lib/scheduling.test.ts (15 tests), package.json test:unit. Review: APPROVED. test:unit 15/15. Minor (non-blocking): add direct test for hasConflict positive case, clamp01, parseWorkingHours malformed-shape, specialtyMatch substring branch — defer to final review.
- Task 3: complete — appended DB fns to scheduling.ts (getCandidates, getExistingBusy, isAvailable, rankOne, pickBestSlotEmployee, autoAssign, runInTransaction). Plan typo `c.info.id``c.id` fixed & verified. Review: APPROVED. typecheck 0, test:unit 15/15, tx smoke ok. Low-note: getExistingBusy scopes by start_at within day (cross-midnight appts not caught — neutralized by working-hours guard; revisit if 24h scheduling added).
- Task 4: complete — booking.ts publicBusiness (+auto_assign_specialist,working_hours) + slots endpoint uses real working_hours + pickBestSlotEmployee. POST /book untouched. Review: APPROVED. typecheck 0, slots smoke ok. Info-notes: local overlapsRange dup of overlaps (plan-permitted); per-slot SELECT name could be hoisted (pre-existing).
- Task 5: complete — booking.ts POST /book rewritten: runInTransaction + autoAssign (when emp omitted or auto_assign_specialist=1) + isAvailable guard → 409 on conflict; 201 includes employee_id+reasons. Review: APPROVED. typecheck 0, test:unit 15/15, integration a)201 b)409 c)201-survivor d)409-autoAssign-null all pass. Sound tx guard.
- Task 6: complete — appointments.ts POST / wrapped in runInTransaction, autoAssign fallback (replaced LIMIT 1), isAvailable guard → 409. Two plan-text deviations (move emp resolution inside tx; Number(business_id)) verified correct. Review: APPROVED. typecheck 0, test:unit 15/15, conflict smoke 409/201 pass. FOLLOW-UP: e2e-test.mjs fixtures (2026-09-15T11:00Z=05:00 local, 2026-09-20 Sun) now fail under working-hours enforcement — fixture-side, will fix in Task 7.
- Task 7: complete — settings.ts (+auto_assign_specialist,working_hours, JSON/0-1 coercion), employees.ts (+specialties/working_hours/efficiency_score), booking-e2e.mjs (new), e2e-test.mjs slot-aware fixture fix (no guards weakened). Review: APPROVED. typecheck 0, test:unit 15/15, test:e2e 25/25, test:booking 9/9.
== BACKEND COMPLETE (Tasks 1-7). Algorithm + guards + APIs all verified green. ==
- Task 8: complete — index.css wrapped .input/.select/.textarea in @layer components; BookingPage DetailsForm 3× pl-9→pl-10. Verified directly by controller (build green, @layer confirmed at index.css:272, WIP untouched). Icon/placeholder overlap fixed app-wide.
- Task 9: complete — publicApi.ts types (PublicBusiness.auto_assign_specialist; BookPayload.employee_id optional; BookResponse +employee_id/reasons). Verified directly (typecheck 0, no consumer breakage).
- Task 10: complete — src/components/MonthCalendar.tsx (42-cell month grid, nav bounded by min/max, brand selected, accent avail dot, brand today dot). Verified directly (typecheck 0, build green). Will be integration-tested in Task 11.
- Task 11: complete — BookingPage DateTimePicker rewritten: md+ 2-col (MonthCalendar left + grouped slots right), SlotGroup Mañana/Tarde, mobile stacked, slot button styles unchanged, max optional. Review: APPROVED. typecheck 0, build ok. AM/PM noon boundary verified.
- Task 12: complete — BookingPage wizard rewired to dynamic 3/4 steps via stepsFor(autoAssign)+currentOriginal; main max-w-5xl only on Horario; handleBook sends employee_id undefined when autoAssign; Confirmation shows reasons badges. Review: APPROVED. typecheck 0, build ok, HTTP smoke both flows + 409. NOTE: `npm run lint` can't run repo-wide (pre-existing ESLint9 flat-config gap, no eslint.config.js) — pre-existing, will report at Task 15.
- Task 13: complete — src/lib/workingHours.ts (DEFAULT_WH/DAYS/parseWh) + SettingsPage auto-assign checkbox + 7-day working-hours editor. Review: APPROVED. typecheck 0, build ok, runtime e2e (PATCH→GET persist, Sat 10-14 slots appear, toggle 3/4 steps).
- Task 14: complete — EmployeesPage EmployeeModal +specialties chips +efficiency slider +working-hours editor (inherit toggle). Payload specialties[]/efficiency Number/working_hours null|map. Review: APPROVED. typecheck 0, build ok. Low-notes: all-closed employee flips back to inherit (benign); case-sensitive dedup (acceptable). Runtime persistence smoke deferred to Task 15.
== ALL 14 IMPLEMENTATION TASKS COMPLETE + REVIEWED. ==
== TASK 15: FINAL VERIFICATION ==
- typecheck: 0 errors
- test:unit: 15/15
- build: OK (2462 modules)
- test:e2e: 25/25
- test:booking: 9/9 (auto-assign 201+reasons, double-book 409, auto_assign_specialist exposed)
- employee persistence runtime check (Task 14 deferred): specialties/efficiency=87/working_hours custom→null(inherit) all persist ✓
- lint: PRE-EXISTING repo-wide failure (no eslint.config.js, ESLint 9.39 requires flat config) — NOT introduced by this feature.
- FINAL WHOLE-BRANCH REVIEW: READY TO MERGE. Core guarantee (no double-booking from public flow) verified end-to-end (sync handler + BEGIN IMMEDIATE + hasConflict + same-tx INSERT). No cross-task integration issues.
== POST-MERGE FOLLOW-UPS (non-blocking, deferred) ==
1. getExistingBusy: switch WHERE to overlap-based (end_at > dayStart AND start_at < dayEnd) — closes cross-midnight gap if 24h scheduling added.
2. Add 4 unit-test cases (hasConflict positive, clamp01, parseWh malformed, specialtyMatch substring).
3. Replace local overlapsRange with imported overlaps.
4. Hoist per-slot employee-name SELECT out of slots loop.
5. MonthCalendar nav buttons → h-11 w-11 (44px touch target).
== DEMO EMAIL DOMAIN TASK ==
- Task 1: complete — source constants, README credentials, active fixtures, and executable plan login example updated. Commits 6355d18..50dea33; review clean.
- Task 2: complete — local data/agendapro.db migrated 2 users; second update changed 0; appointments 161 and clients 15 preserved; review clean.
- Task 3: complete with limitation — typecheck/build/unit/admin passed; auth paths passed; e2e/booking slot assertions remain blocked by pre-existing null working-hours data, not the email change; review approved.
- Task 4: complete — committed source/docs through 3e056a0, pushed to GitHub and Gitea, Coolify deployment wbnbq9b0kneba67sznu7p1qf finished, runtime container healthy.
- Task 5: complete — production health/page passed, new admin/owner logins returned 200, old-domain logins returned 401; active DB was fresh and seeded with new emails, so targeted SQL was a no-op.
- Final review: complete — final-fix review approved; regex acceptance fix review approved.
== AGENDAMAX PWA INSTALLABILITY TASK ==
- Task 1: complete — manifest, iOS metadata, reproducible Playwright-generated 192/512 PNG icons, and package scripts. Commits 9d2ab39..de947f9; review clean.
- Task 2: complete — versioned shell/static service worker and production-only registration. Commits de947f9..65233f2; review clean.
- Task 3: complete — install prompt hook/component, iOS Safari guide, safe-area styling, and Login/AppShell/AdminShell integration; review fixes added prompt guard, appinstalled precedence, close-button label, and compact mobile layout. Commits 65233f2..9ded129; review clean.
- Task 4: complete — production-server PWA endpoint/browser tests, online/offline API bypass checks, three-shell coverage, and install documentation; review clean. Commits 9ded129..43f5d13.
- Final fix wave: complete — credential-aware cache policy, AgendaMax-scoped cleanup, session dismissal persistence, configurable PWA test credentials, dialog semantics, and dismissal regression coverage. Commit 0b466f3; final whole-feature review approved.
== LANDING PÚBLICA + ACCESO DE UN CLIC (2026-07-28) ==
Spec: docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md
Plan: docs/superpowers/plans/2026-07-28-landing-funnel-bi.md
Ruteo: `/` sirve la landing (pública, React.lazy); el login pasa a `/login`. `homePathFor(user)`
centraliza el destino por rol. `/b/:slug` intacto, fuera del AuthProvider.
Dos iteraciones de diseño a pedido del usuario:
1. Primera versión oscura/sobria (criterio Apple). Entregada y verificada.
2. Rediseño a claro, vívido y animado, con copy humano para dueñas de salón y público de 50+
(guía de dolores: Base de Conocimiento - IA Negocios y Dev/03-Preguntas-Guia.md, Q15/Q72/Q87:
hablar de lo que gana el cliente, no de lo que hace el sistema). Se eliminó todo vocabulario de
sistema del copy visible: ni «business intelligence», ni «datos», ni «métricas», ni «panel».
3. Paleta rebasada sobre los colores REALES del panel a pedido del usuario: las 8 muestras del
abanico del hero son PIE_COLORS de DashboardPage.tsx; la marca sale de public/favicon.svg vía
src/components/BrandMark.tsx (única fuente); CTA = brand-500→brand-700 como .btn-primary.
Defectos encontrados y corregidos durante la verificación (no estaban en el plan):
- El chunk de entrada arrastraba framer-motion (39 KB gz) a TODA carga del panel, porque LoginPage
era eager. Se hizo lazy junto con la landing.
- La landing descargaba recharts (111 KB gz) y FullCalendar (76 KB gz) sin usarlos, porque
DashboardPage y CalendarPage eran eager. Ambas a lazy, con Suspense en el <Outlet> de AppShell.
- `clsx` lo comparten lib/format.ts y recharts; Rollup lo asignaba al chunk `charts`, así que el
entry importaba 111 KB para una utilidad de 200 bytes. Fijado al chunk `react`.
- LOGIN CON EL TEXTO PEGADO AL BORDE en móvil: `.safe-x` está fuera de @layer y gana a `px-5`,
dejando el padding en 0 con inset 0 (todo lo que no sea iPhone landscape). Nueva clase `.ld-gutter`.
Ningún check existente lo detectaba (no hay desborde: hay cero margen) → añadido check `gutter`.
- `shell-height` del audit daba falso positivo en toda página que scrollea: comparaba #root contra
el viewport vía el fallback a body.firstElementChild. Acotado a [data-app-shell]; a cambio se
añadió el check estático `raw-viewport-unit` sobre las fuentes públicas (verificado inyectando
`min-h-screen` en ProofSection: dispara).
- Rótulo «Como una de tus muchachas» en /login con Diego Castillo en la lista debajo. Reescrito sin
asumir género («Como alguien de tu equipo»); igual en ObjectionsSection.
- Anclas bajo el nav fijo (scroll-mt-24); degradado del cierre invisible tras `-z-10`; etiquetas del
abanico ilegibles (eliminadas); avisos flotantes solapando el abanico (reubicados a las 3 zonas
libres) y detalle truncado (acortado).
Audits ajustados por el cambio de ruteo (`/``/login`): visual-audit.mjs, responsive-audit.mjs y
los SIETE `goto(baseUrl)` de pwa-e2e.mjs que esperan InstallAppPrompt. La landing se añadió a las
listas de páginas de los dos audits.
VERIFICACIÓN FINAL (todo en verde):
- typecheck: 0 errores
- test:landing (NUEVO, WebKit): 13/13 — ruteo, acceso de un clic, sin desborde a 390px y
prefers-reduced-motion sin contenido invisible en las 7 secciones
- audit:responsive: 0 hallazgos (9 dispositivos × 10 páginas)
- audit:visual: 62 pantallas, 0 fallos, 0 desborde, 0 errores de consola
- test:pwa sobre el build: passed
- test:unit 39/39 · test:e2e 33/33 · test:admin 17/17 · test:booking 12/12
- build: el chunk de entrada NO importa charts, framer ni calendar (verificado en dist/)
- WebKit a 390/440/744/820/1024/1440 en `/` y `/login`: sin desborde, sin campos <16px, sin
objetivos <40px, sin errores
`npm run lint` sigue roto por falta de eslint.config.js (preexistente).
---
## 2026-07-28 — Commit y despliegue a producción (pedido explícito)
Commit `4c19244` en `main` (53 archivos), empujado a **gitea** y a **github**. Se rompe aquí la
política de "sin commits" porque el usuario lo pidió explícitamente. `graphify-out/` pasó a
.gitignore: es regenerable y ensuciaba todo `git status`.
DEFECTO DE DESPLIEGUE ENCONTRADO ANTES DE SUBIR (el más importante de esta tanda):
el login mágico **no existía en producción y la landing lo habría prometido**. `DEMO` es
`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`, una constante de build: en un build de
producción `DEV` es false y Vite eliminaba por dead-code elimination el acceso de un clic y el
«Ver como…». Comprobado sobre el bundle que producción servía en ese momento: `agendamax.demo`,
`cuentas demo`, `Ver como` y `Cambiar cuenta` todos AUSENTES; el único `demo1234` que sobrevivía
está en `switchUser` de auth.tsx, que no está detrás de la bandera. Sin arreglarlo, la landing decía
«no pide registro» y llevaba a un formulario vacío.
Arreglo: `ARG VITE_DEMO_UI=1` + `ENV` en la etapa `web-build` del Dockerfile, antes de
`npm run build`. Default en 1 porque este despliegue **es** la demostración; `--build-arg
VITE_DEMO_UI=0` lo apaga sin tocar código. Verificado construyendo de las dos formas: con la bandera
aparecen `data-magic-login`, `[email protected]`, `Ver como` y `Cambiar cuenta`; sin ella
desaparecen las cuatro. En ambos casos el entry sigue importando solo icons/query/react.
También: `CACHE_NAME` del service worker a `agendamax-shell-v2`. No toqué su lógica, pero la landing
cambió el app shell y partió el bundle en chunks nuevos; como las peticiones de assets son
cache-first por nombre con hash, los chunks viejos quedarían huérfanos para siempre en el caché de
quien ya visitó el sitio. El `activate` los purga al cambiar el sufijo. `pwa-e2e.mjs` solo verifica
el prefijo, así que no hubo que tocar el test.
DESPLIEGUE (Coolify 4.1.2, app `s30f7egdlkx4wyjp59o1iunc`, build pack dockerfile, rama main,
fuente `urieljarethbusiness-cpu/agendamax`):
- El **auto-deploy por webhook sí está configurado**: el push a github disparó el deploy 145 solo.
Mi `POST /deploy?force=true` (146) quedó encolado detrás y fue redundante.
- Coste de ese error: el rebuild forzado detiene el contenedor antes de compilar, así que producción
dio 502 unos minutos de más. Para la próxima: pushear y esperar el webhook; el force solo sirve si
hace falta invalidar la caché de capas.
- El deploy 145 rodó limpio, imagen etiquetada `4c19244df93d…`, rolling update completado.
VERIFICACIÓN EN PRODUCCIÓN (https://agendamax.urieljareth.org, WebKit, 16/16):
- El hash del bundle que sirve producción (`index-D_TEdX1K.js`) es idéntico al del build local CON
la bandera. Un build sin ella da otro hash, así que es prueba de que el flag se aplicó.
- Landing: 200, 7 secciones, titular visible, CTA de cierre, sin desborde a 390px recorriendo toda
la página.
- Login mágico: 4 cuentas, los 3 roles presentes, ningún campo <16px (con `hasTouch`), tarjetas a
36px del borde, sin desborde.
- Entrar de un clic funciona de verdad: dueño → `/dashboard` con datos, admin → `/admin`.
- Sin errores de JS. Service worker `agendamax-shell-v2` con el bypass de `/api` intacto.
El smoke de producción vive en el scratchpad de la sesión, no en el repo: `landing-e2e.mjs` ya cubre
lo mismo en local y no se pidió otro test versionado. Si se va a desplegar seguido, vale la pena
promoverlo a `npm run test:prod`.
Dos trampas de medición que costaron una pasada en falso, por si reaparecen:
- Un contexto de Playwright **sin `hasTouch`** reporta `pointer: fine`, así que el `@media (pointer:
coarse)` que sube los campos a 16px no aplica y el check falla midiendo un CSS que el dispositivo
real nunca ve. `responsive-audit.mjs` ya lo hacía bien.
- `LoginPage` navega a `/` y es la ruta `/` la que redirige según el rol. Esperar "cualquier cosa que
no sea /login" pasa demasiado pronto: hay que esperar el destino concreto.
- `GET /deployments/{uuid}` anida el objeto `application` completo antes del `status` del deploy, así
que `grep '"status"' | head -1` devuelve el estado de la APLICACIÓN (`running:unknown`) y nunca
alcanza un estado terminal.
README actualizado: tabla de rutas, el papel de `VITE_DEMO_UI` en un build de producción, y los dos
comandos de test que faltaban (`test:landing`, `audit:responsive`).
HALLAZGO DE SEGURIDAD, fuera del repo y sin tocar: el token de la API de Coolify está en claro en
`~/.openclaw/workspace/check_coolify.ps1`, y `~/coolify-agent-skill.md` tiene correo, contraseña y
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.
2. La landing no tiene contenido de precios ni captura de leads: en fase demo la conversión es entrar
a la demo. Habrá que decidirlo antes de un lanzamiento real.
3. Seguridad sin cambios: `/login` expone contraseñas de cuentas existentes bajo la bandera DEMO.
Autorizado solo para esta fase; es lo único que separa esto de una fuga si se publica.