Files
AgendaPro/docs/superpowers/plans/2026-07-28-landing-funnel-bi.md
T
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:25:58 -06:00

2621 lines
99 KiB
Markdown

# Landing de funnel BI + login mágico — Plan de implementación
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Convertir `/` en una landing pública de funnel con enfoque business intelligence y estética Apple, moviendo el login a `/login` con acceso mágico de un clic a las cuentas demo.
**Architecture:** La landing es una ruta pública nueva, cargada con `React.lazy`, compuesta por ocho secciones independientes de cero props. `framer-motion` entra como dependencia aislada en su propio chunk de Rollup para que el panel no la pague. El login se rediseña reutilizando el endpoint `/api/auth/demo-users` que ya existe — no se toca el servidor.
**Tech Stack:** React 18, TypeScript, Vite 5, Tailwind 3, react-router-dom 6, framer-motion 11, Playwright (audits).
**Spec:** [docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md](../specs/2026-07-28-landing-funnel-bi-design.md)
## Global Constraints
- **Node.js >= 22.5.** Todo script de servidor pasa por `scripts/run-tsx.mjs`, nunca `npx tsx` directo.
- **Windows/PowerShell:** usa `npm.cmd` si `npm.ps1` está bloqueado por execution policy.
- **Instalar con `npm run setup`** (`npm ci`, caché en `.cache/npm`). La única excepción de este plan es `npm install framer-motion@^11` en la Tarea 1, que sí debe mutar `package.json` y el lockfile.
- **NO HAGAS COMMITS.** Política del repo registrada en `.superpowers/sdd/progress.md` y en `CLAUDE.md`. La verificación por tarea es `npm run typecheck` + tests, nunca un commit. Los pasos de este plan terminan en verificación, no en `git commit`.
- **`npm run lint` está roto** (ESLint 9 sin `eslint.config.js`). No lo uses como señal. El gate real es `npm run typecheck`.
- **Idioma:** todo string visible al usuario en español de LATAM. Los comentarios de código, en español.
- **Nunca `100vh`.** Usa `.min-h-screen-safe` / `.h-screen-safe` / `.max-h-screen-safe`, que declaran `vh` como fallback y `dvh` encima. `100vh` en iOS no descuenta la barra de URL dinámica.
- **Ningún campo de formulario por debajo de 16px en táctil.** Lo garantiza la regla global de `@media (pointer: coarse)` en `src/index.css`; no la anules con utilidades `text-sm` en inputs.
- **Objetivos táctiles por `pointer: coarse`, no por breakpoints `sm:`.** Un `sm:` deja fuera a las tablets, que también se tocan.
- **Todo `grid` con `grid-cols-1` explícito.** Sin él la columna implícita se dimensiona a `max-content` y desborda en horizontal.
- **`prefers-reduced-motion: reduce` resuelve los `initial` al estado final**, no acorta transiciones. Un `initial={{opacity:0}}` que nunca anima deja el contenido invisible.
- **Easing único en toda la landing:** `cubic-bezier(0.16, 1, 0.3, 1)`, exportado como `EASE_EXPO`.
- **Puertos:** `dev` usa `:3000` (API) y `:5173` (Vite, con proxy `/api`). Los audits apuntan a `:5173` excepto `pwa-e2e.mjs`, que apunta a `:3000` sobre el build.
## Mapa de archivos
| Archivo | Responsabilidad |
|---|---|
| `src/lib/motion.ts` | **Crear.** Easing y variants compartidos. Único lugar donde se define la curva. |
| `src/lib/useReducedMotion.ts` | **Crear.** Hook booleano sobre `prefers-reduced-motion`, reactivo a cambios en vivo. |
| `src/index.css` | **Modificar.** Añade el scope `.landing` con sus tokens y las utilidades tipográficas `.ld-*`. |
| `vite.config.ts` | **Modificar.** Chunk manual `motion`. |
| `src/App.tsx` | **Modificar.** Rutas públicas `/` y `/login`, helper `homePathFor`, `React.lazy` de la landing. |
| `src/pages/LandingPage.tsx` | **Crear.** Orquestador. Solo compone secciones. Export **default** (lo exige `lazy`). |
| `src/components/landing/LandingNav.tsx` | **Crear.** Nav flotante que se condensa al scrollear. |
| `src/components/landing/HeroSection.tsx` | **Crear.** Sección 1. |
| `src/components/landing/CostSection.tsx` | **Crear.** Sección 2, con contadores animados. |
| `src/components/landing/ShiftSection.tsx` | **Crear.** Sección 3, sticky con scrub por scroll. |
| `src/components/landing/ProductSection.tsx` | **Crear.** Sección 4, tres actos con panel sticky. |
| `src/components/landing/ProofSection.tsx` | **Crear.** Sección 5. |
| `src/components/landing/ObjectionsSection.tsx` | **Crear.** Sección 6. |
| `src/components/landing/ClosingSection.tsx` | **Crear.** Sección 7 + footer. |
| `src/components/landing/visuals/CalendarGridVisual.tsx` | **Crear.** Retícula que se puebla de citas. |
| `src/components/landing/visuals/RevenueLineVisual.tsx` | **Crear.** Línea de ingresos que se traza. |
| `src/components/landing/visuals/TeamLoadVisual.tsx` | **Crear.** Barras de carga por especialista. |
| `src/components/landing/visuals/NotebookVisual.tsx` | **Crear.** Cuaderno con tachones. |
| `src/components/landing/visuals/DashboardMockVisual.tsx` | **Crear.** Composición del panel a escala. |
| `src/pages/LoginPage.tsx` | **Modificar.** Rediseño con acceso mágico como camino principal. |
| `landing-e2e.mjs` | **Crear.** Test de aceptación Playwright. |
| `package.json` | **Modificar.** `framer-motion` + script `test:landing`. |
| `visual-audit.mjs` | **Modificar.** `/``/login`; añade la landing a `PAGES`. |
| `responsive-audit.mjs` | **Modificar.** `/``/login`; añade la landing a `PAGES`. |
| `pwa-e2e.mjs` | **Modificar.** Los siete `goto(baseUrl)` que esperan el login pasan a `url("/login")`. |
**No se toca:** `server/`, `shared/types.ts`, el esquema, las migraciones, `public/sw.js`, ni los tests de API (`e2e-test.mjs`, `admin-test.mjs`, `server/scripts/booking-e2e.mjs`, `server/lib/*.test.ts`).
---
### Task 1: Fundamentos de movimiento y tokens de la landing
**Files:**
- Modify: `package.json` (dependencias + script `test:landing`)
- Modify: `vite.config.ts:33-45` (manualChunks)
- Create: `src/lib/motion.ts`
- Create: `src/lib/useReducedMotion.ts`
- Modify: `src/index.css` (añadir al final del `@layer components` existente y una regla fuera de capa)
**Interfaces:**
- Consumes: nada.
- Produces:
- `EASE_EXPO: readonly [number, number, number, number]`
- `baseTransition: Transition`
- `revealUp(reduced: boolean, distance?: number): Variants`
- `revealStagger(reduced: boolean, stagger?: number): Variants`
- `inView: { once: true; margin: string }`
- `useReducedMotion(): boolean`
- Clases CSS: `.landing`, `.landing-dark`, `.landing-light`, `.ld-display`, `.ld-lead`, `.ld-eyebrow`, `.ld-section`, `.ld-wrap`, `.ld-num`
- [ ] **Step 1: Instalar framer-motion**
Este es el único paso del plan que muta `package.json` y el lockfile a propósito.
```bash
npm.cmd install framer-motion@^11
```
- [ ] **Step 2: Verificar que quedó en `dependencies`, no en `devDependencies`**
Run: `npm.cmd ls framer-motion --depth=0`
Expected: imprime `[email protected]` sin la marca `(dev)`. Si cayó en devDependencies, muévelo a mano en `package.json` y repite `npm.cmd install`.
- [ ] **Step 3: Añadir el chunk manual `motion` en `vite.config.ts`**
En el objeto `manualChunks` (líneas 33-45), añade la entrada `motion` después de `icons`:
```ts
manualChunks: {
react: ["react", "react-dom", "react-router-dom"],
calendar: [
"@fullcalendar/core",
"@fullcalendar/react",
"@fullcalendar/daygrid",
"@fullcalendar/timegrid",
"@fullcalendar/interaction",
],
charts: ["recharts"],
query: ["@tanstack/react-query"],
icons: ["lucide-react"],
// La landing es la única que anima. Sin este chunk, Rollup mete
// framer-motion en el bundle compartido y sus ~50 KB gzip se cobran en
// cada carga del panel, que nunca lo usa.
motion: ["framer-motion"],
},
```
- [ ] **Step 4: Añadir el script `test:landing` en `package.json`**
En `scripts`, justo después de `"test:booking"`:
```json
"test:landing": "node landing-e2e.mjs",
```
- [ ] **Step 5: Crear `src/lib/motion.ts`**
```ts
import type { Transition, Variants } from "framer-motion";
/**
* ease-out-expo. Un solo easing en toda la landing: es lo que produce la
* sensación de que los elementos "pesan algo y frenan solos", y mezclar curvas
* es exactamente lo que hace que una landing se sienta improvisada.
*/
export const EASE_EXPO = [0.16, 1, 0.3, 1] as const;
export const baseTransition: Transition = { duration: 0.7, ease: EASE_EXPO };
/**
* Reveal vertical. `reduced` colapsa el estado inicial al final: NO acorta la
* transición, la elimina. Si `hidden` dejara `opacity: 0` cuando el usuario pide
* menos movimiento, el contenido quedaría invisible para siempre porque la
* animación que lo revela nunca se dispara.
*/
export function revealUp(reduced: boolean, distance = 24): Variants {
return {
hidden: reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: distance },
show: { opacity: 1, y: 0, transition: baseTransition },
};
}
/** Contenedor que escalona a sus hijos. Con movimiento reducido, stagger 0. */
export function revealStagger(reduced: boolean, stagger = 0.08): Variants {
return {
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : stagger } },
};
}
/**
* Props de viewport compartidas para `whileInView`. Una sola pasada (`once`), con
* margen negativo para que el reveal arranque cuando la sección ya entró de
* verdad y no en el instante en que su borde roza la pantalla.
*/
export const inView = { once: true, margin: "-12% 0px -12% 0px" } as const;
```
- [ ] **Step 6: Crear `src/lib/useReducedMotion.ts`**
```ts
import { useEffect, useState } from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
/**
* `true` cuando el sistema pide menos movimiento. Se suscribe a los cambios: en
* iOS la preferencia se activa desde Ajustes sin recargar la pestaña, así que
* leerla una sola vez al montar dejaría la landing animando de todas formas.
*/
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(() => window.matchMedia(QUERY).matches);
useEffect(() => {
const mq = window.matchMedia(QUERY);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", onChange);
// Resincroniza: la preferencia pudo cambiar entre el render inicial y el efecto.
setReduced(mq.matches);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
```
- [ ] **Step 7: Añadir los tokens de la landing en `src/index.css`**
Dentro del `@layer components` que ya existe (empieza en la línea 415), **después** del bloque `.input, .select, .textarea`, añade:
```css
/* ---- Landing pública ----
Scope propio porque el body del panel es #f6f7fb con color-scheme: light y la
landing necesita secciones oscuras a sangre sin alterar el panel. Va DENTRO de
la capa (el default del repo) para que cualquier utilidad de Tailwind aplicada
en el JSX gane por especificidad de capa. */
.landing {
--ld-ink: #08080a; /* casi-negro: el negro puro aplana y delata el gradiente */
--ld-paper: #fafafa;
--ld-muted: #6b6b73; /* secundario sobre papel */
--ld-muted-dark: #8a8a94; /* secundario sobre tinta */
--ld-hairline: rgba(255, 255, 255, 0.09);
min-height: 100vh;
min-height: 100dvh;
overflow-x: clip;
}
.landing-dark {
background: var(--ld-ink);
color: var(--ld-paper);
}
.landing-light {
background: var(--ld-paper);
color: var(--ld-ink);
}
/* Escala fluida sin media queries: clamp() con vw evita los saltos entre
breakpoints que delatan una landing armada con utilidades sueltas. */
.ld-display {
font-size: clamp(2.5rem, 7.5vw, 5.75rem);
line-height: 0.98;
letter-spacing: -0.035em;
font-weight: 800;
}
.ld-title {
font-size: clamp(1.875rem, 4.5vw, 3.25rem);
line-height: 1.05;
letter-spacing: -0.025em;
font-weight: 800;
}
.ld-lead {
font-size: clamp(1.0625rem, 2.1vw, 1.375rem);
line-height: 1.5;
}
.ld-eyebrow {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* El padding vertical ES el "silencio" del criterio Apple: sin él, ninguna
cantidad de tipografía grande hace que se lea caro. */
.ld-section {
padding-block: clamp(5rem, 12vh, 11rem);
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
}
.ld-wrap {
width: 100%;
max-width: 72rem;
margin-inline: auto;
}
/* Números que se interpolan frame a frame. Sin tabular-nums cada dígito cambia
de ancho y la línea entera relayoutea en cada frame. */
.ld-num {
font-variant-numeric: tabular-nums;
}
```
- [ ] **Step 8: Permitir que la landing crezca más allá de `#root`**
`html, body, #root` están fijados a `height: 100%` (líneas 26-30) para el shell de altura fija del panel. La landing es mucho más alta que el viewport. Añade **fuera** de `@layer`, justo después del bloque `html, body, #root`:
```css
/* El shell del panel vive en un #root de altura fija; la landing, no. Se relaja
solo cuando la landing está montada, así el panel conserva su altura fija.
:has() está soportado en Safari 15.4+, dentro del rango de iOS objetivo. */
body:has(.landing) #root {
height: auto;
min-height: 100%;
}
```
- [ ] **Step 9: Verificar que compila**
Run: `npm.cmd run typecheck`
Expected: sin errores. `motion.ts` y `useReducedMotion.ts` aún no tienen consumidores, así que solo se valida que los tipos de `framer-motion` resuelven.
---
### Task 2: Test de aceptación (debe fallar)
Escribe primero el test que define «hecho». Debe fallar ahora y quedar verde al final de la Tarea 9.
**Files:**
- Create: `landing-e2e.mjs`
**Interfaces:**
- Consumes: `test:landing` de `package.json` (Tarea 1, paso 4).
- Produces: contrato de atributos de datos que las tareas 3-9 **deben** cumplir:
- `[data-ld-section]` — uno por sección de la landing; deben ser 7.
- `[data-ld-hero-title]` — el `h1` del hero.
- `[data-ld-closing-cta]` — el CTA de la sección de cierre.
- `[data-magic-login]` — una por tarjeta de cuenta demo en `/login`.
- `[data-magic-login][data-role="owner"]` — la tarjeta que entra como dueña.
- [ ] **Step 1: Escribir el test**
```js
// landing-e2e.mjs — aceptación de la landing pública y del acceso mágico.
//
// Cubre lo que ningún test existente cubre: que `/` sirva la landing en lugar del
// login, que `/login` siga siendo alcanzable con su formulario sin interacción
// previa (de eso dependen los otros tres audits), que el acceso mágico entre de un
// clic, y que la landing no desborde en horizontal ni esconda contenido cuando el
// sistema pide menos movimiento.
//
// Uso: npm run test:landing (requiere `npm run dev` corriendo)
import assert from "node:assert/strict";
const BASE = process.env.LANDING_BASE_URL || "http://localhost:5173";
const OWNER_EMAIL = "[email protected]";
const PASSWORD = "demo1234";
const T = 20000;
async function pickBrowser() {
const pw = await import("playwright");
for (const name of ["webkit", "chromium"]) {
try {
return { browser: await pw[name].launch(), engine: name };
} catch {
/* siguiente motor */
}
}
throw new Error("No se pudo lanzar ningún navegador de Playwright.");
}
const passed = [];
async function check(name, fn) {
await fn();
passed.push(name);
console.log(` ok ${name}`);
}
async function main() {
const { browser, engine } = await pickBrowser();
console.log(`Motor: ${engine}\nBase: ${BASE}\n`);
try {
// ---- 1. `/` sirve la landing, no el login ----
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
ctx.setDefaultTimeout(T);
ctx.setDefaultNavigationTimeout(T);
const page = await ctx.newPage();
const pageErrors = [];
page.on("pageerror", (e) => pageErrors.push(e.message));
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await check("`/` monta la landing", async () => {
await page.waitForSelector("[data-ld-hero-title]", { state: "visible" });
});
await check("`/` no expone el formulario de login", async () => {
assert.equal(await page.locator('input[type="email"]').count(), 0);
});
await check("la landing tiene las 7 secciones del funnel", async () => {
assert.equal(await page.locator("[data-ld-section]").count(), 7);
});
await check("la landing no lanza errores de runtime", async () => {
assert.deepEqual(pageErrors, []);
});
// ---- 2. `/login` conserva el formulario sin interacción previa ----
// Es un requisito duro: visual-audit, responsive-audit y pwa-e2e rellenan
// estos campos directo tras el goto. Colapsarlos rompería los tres.
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
await check("`/login` muestra el formulario manual sin abrir nada", async () => {
await page.waitForSelector('input[type="email"]', { state: "visible" });
await page.waitForSelector('input[type="password"]', { state: "visible" });
await page.waitForSelector('button[type="submit"]', { state: "visible" });
});
await check("`/login` ofrece al menos 3 cuentas de acceso mágico", async () => {
const n = await page.locator("[data-magic-login]").count();
assert.ok(n >= 3, `esperaba >= 3 tarjetas, encontré ${n}`);
});
// ---- 3. El acceso mágico entra de un clic ----
await check("un clic en la tarjeta de dueña entra al panel", async () => {
await page.locator('[data-magic-login][data-role="owner"]').first().click();
await page.waitForURL(/\/dashboard/, { timeout: T });
});
// ---- 4. Con sesión, `/` redirige al panel ----
await check("`/` redirige al panel cuando hay sesión", async () => {
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await page.waitForURL(/\/dashboard/, { timeout: T });
});
const authed = await ctx.storageState();
await ctx.close();
// ---- 5. Sin sesión, una ruta protegida manda al login ----
const anon = await browser.newContext({ viewport: { width: 1280, height: 900 } });
anon.setDefaultTimeout(T);
anon.setDefaultNavigationTimeout(T);
const anonPage = await anon.newPage();
await check("`/dashboard` sin sesión redirige a `/login`", async () => {
await anonPage.goto(`${BASE}/dashboard`, { waitUntil: "networkidle" });
await anonPage.waitForURL(/\/login/, { timeout: T });
});
await anon.close();
// ---- 6. Sin desborde horizontal en el teléfono más estrecho del objetivo ----
const phone = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
hasTouch: true,
});
phone.setDefaultTimeout(T);
phone.setDefaultNavigationTimeout(T);
const phonePage = await phone.newPage();
await phonePage.goto(`${BASE}/`, { waitUntil: "networkidle" });
await phonePage.waitForSelector("[data-ld-hero-title]", { state: "visible" });
await check("la landing no desborda en horizontal a 390px", async () => {
// Se recorre toda la página: las secciones sticky y los visuales anchos solo
// desbordan una vez que entran en viewport y arrancan su animación.
const overflow = await phonePage.evaluate(async () => {
const doc = document.documentElement;
let worst = 0;
const steps = Math.ceil(doc.scrollHeight / window.innerHeight) + 1;
for (let i = 0; i < steps; i += 1) {
window.scrollTo(0, i * window.innerHeight);
await new Promise((r) => setTimeout(r, 350));
worst = Math.max(worst, Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth);
}
return worst;
});
assert.ok(overflow <= 1, `desborde de ${overflow}px`);
});
await phone.close();
// ---- 7. Movimiento reducido no esconde contenido ----
// El fallo que se busca: un initial={{opacity:0}} cuyo whileInView nunca corre
// deja el bloque invisible de forma permanente.
const calm = await browser.newContext({
viewport: { width: 1280, height: 900 },
reducedMotion: "reduce",
});
calm.setDefaultTimeout(T);
calm.setDefaultNavigationTimeout(T);
const calmPage = await calm.newPage();
await calmPage.goto(`${BASE}/`, { waitUntil: "networkidle" });
await check("con movimiento reducido el hero es visible", async () => {
const o = await calmPage
.locator("[data-ld-hero-title]")
.evaluate((el) => Number(getComputedStyle(el).opacity));
assert.ok(o >= 0.99, `opacity del hero = ${o}`);
});
await check("con movimiento reducido el CTA de cierre es visible", async () => {
const cta = calmPage.locator("[data-ld-closing-cta]");
await cta.scrollIntoViewIfNeeded();
const o = await cta.evaluate((el) => Number(getComputedStyle(el).opacity));
assert.ok(o >= 0.99, `opacity del CTA de cierre = ${o}`);
});
await check("con movimiento reducido todas las secciones son visibles", async () => {
const hidden = await calmPage.evaluate(async () => {
const out = [];
const nodes = [...document.querySelectorAll("[data-ld-section]")];
for (const [i, el] of nodes.entries()) {
el.scrollIntoView();
await new Promise((r) => setTimeout(r, 250));
const invisibles = [...el.querySelectorAll("*")].filter(
(n) => Number(getComputedStyle(n).opacity) < 0.05 && n.textContent?.trim()
);
if (invisibles.length) out.push({ section: i, count: invisibles.length });
}
return out;
});
assert.deepEqual(hidden, []);
});
await calm.close();
console.log(`\n${passed.length} comprobaciones OK`);
void authed;
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(`\nlanding-e2e falló: ${error.message}`);
process.exitCode = 1;
});
```
- [ ] **Step 2: Arrancar el entorno de desarrollo**
En una terminal aparte:
```bash
npm.cmd run dev
```
Espera a ver `SERVER` escuchando en `:3000` y `WEB` en `:5173`. Si un puerto está ocupado, el preflight aborta; usa `$env:PORT='3001'; $env:VITE_PORT='5174'; $env:API_URL='http://127.0.0.1:3001'; npm.cmd run dev` y exporta `LANDING_BASE_URL=http://localhost:5174`.
- [ ] **Step 3: Correr el test y confirmar que falla**
Run: `npm.cmd run test:landing`
Expected: FALLA en la primera comprobación, con un timeout esperando `[data-ld-hero-title]`. `/` sigue sirviendo el login, así que ese selector no existe. Ese fallo es la señal correcta de partida.
---
### Task 3: Ruteo público y landing mínima con hero
Al terminar esta tarea deben pasar las comprobaciones 1, 2 (parcial), 4, 5 y 7 del test. Las secciones aún no son 7, así que esa sigue roja.
**Files:**
- Modify: `src/App.tsx` (reescritura completa del archivo)
- Create: `src/pages/LandingPage.tsx`
- Create: `src/components/landing/HeroSection.tsx`
- Create: `src/components/landing/visuals/CalendarGridVisual.tsx`
**Interfaces:**
- Consumes: `revealUp`, `revealStagger`, `inView`, `EASE_EXPO` de `src/lib/motion.ts`; `useReducedMotion` de `src/lib/useReducedMotion.ts`.
- Produces:
- `homePathFor(user: User): string` exportado desde `src/App.tsx`.
- `LandingPage` como **export default** de `src/pages/LandingPage.tsx`.
- `HeroSection` (named export), cero props.
- `CalendarGridVisual` (named export) con props `{ className?: string; density?: number }`.
- [ ] **Step 1: Reescribir `src/App.tsx`**
```tsx
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./lib/auth";
import type { User } from "../shared/types";
import { LoginPage } from "./pages/LoginPage";
import { AppShell } from "./components/AppShell";
import { AdminShell } from "./components/AdminShell";
import { DashboardPage } from "./pages/DashboardPage";
import { CalendarPage } from "./pages/CalendarPage";
import { EmployeesPage } from "./pages/EmployeesPage";
import { ServicesPage } from "./pages/ServicesPage";
import { ClientsPage } from "./pages/ClientsPage";
import { TicketsPage } from "./pages/TicketsPage";
import { ClientDetailPage } from "./pages/ClientDetailPage";
import { CashPage } from "./pages/CashPage";
import { NotificationsPage } from "./pages/NotificationsPage";
import { SettingsPage } from "./pages/SettingsPage";
import { MyPerformancePage } from "./pages/MyPerformancePage";
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
import BookingPage from "./pages/public/BookingPage";
// La landing va aparte del bundle principal por dos razones que apuntan en
// direcciones opuestas, y por eso importan las dos: quien ya tiene sesión nunca la
// ve y no debe descargarla, y quien llega a la landing no debe descargar
// FullCalendar ni recharts para leer una página de venta.
const LandingPage = lazy(() => import("./pages/LandingPage"));
/** Destino del usuario según su rol. Única definición de la regla. */
export function homePathFor(user: User): string {
if (user.role === "admin") return "/admin";
return user.role === "owner" ? "/dashboard" : "/calendar";
}
function Splash() {
return (
<div className="flex h-screen-safe items-center justify-center bg-[#f6f7fb]">
<div className="flex flex-col items-center gap-3 text-slate-500">
<div className="h-9 w-9 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<span className="text-sm font-medium">Cargando AgendaMax</span>
</div>
</div>
);
}
function AppRoutes() {
const { user, loading } = useAuth();
if (loading) return <Splash />;
const home = user ? homePathFor(user) : "/login";
const isAdmin = user?.role === "admin";
return (
<Routes>
{/* ---- Públicas ---- */}
<Route
path="/"
element={
user ? (
<Navigate to={home} replace />
) : (
<Suspense fallback={<Splash />}>
<LandingPage />
</Suspense>
)
}
/>
<Route path="/login" element={user ? <Navigate to={home} replace /> : <LoginPage />} />
{/* Sin sesión, cualquier otra ruta manda al login. */}
{!user && <Route path="*" element={<Navigate to="/login" replace />} />}
{/* ---- Admin de plataforma ---- */}
{user && isAdmin && (
<Route element={<AdminShell />}>
<Route path="/admin" element={<AdminOverviewPage />} />
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
)}
{/* ---- Negocio ---- */}
{user && !isAdmin && (
<Route element={<AppShell />}>
{user.role === "owner" ? (
<Route path="/dashboard" element={<DashboardPage />} />
) : (
<Route path="/dashboard" element={<Navigate to="/calendar" replace />} />
)}
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:id" element={<ClientDetailPage />} />
<Route path="/me" element={<MyPerformancePage />} />
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
{user.role === "owner" && <Route path="/cash" element={<CashPage />} />}
{user.role === "owner" && <Route path="/notifications" element={<NotificationsPage />} />}
{user.role === "owner" && <Route path="/settings" element={<SettingsPage />} />}
{/* La autorización real vive en los middlewares del servidor; esto es UX. */}
<Route path="/admin/*" element={<Navigate to={home} replace />} />
<Route path="*" element={<Navigate to={home} replace />} />
</Route>
)}
</Routes>
);
}
export default function App() {
return (
<Routes>
{/* Reservas públicas: fuera del AuthProvider a propósito, no asumas usuario. */}
<Route path="/b/:slug" element={<BookingPage />} />
<Route
path="*"
element={
<AuthProvider>
<AppRoutes />
</AuthProvider>
}
/>
</Routes>
);
}
```
- [ ] **Step 2: Crear `src/components/landing/visuals/CalendarGridVisual.tsx`**
```tsx
import { motion } from "framer-motion";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
const COLS = 7;
const ROWS = 9;
const DIAS = ["L", "M", "M", "J", "V", "S", "D"];
/** Colores de cita: se repiten para simular varios especialistas. */
const TINTES = ["#3b66ff", "#f17616", "#0ea5e9", "#8b5cf6", "#10b981"];
/**
* Retícula de agenda que se puebla sola. No es una captura: es la promesa del
* producto en movimiento — el día pasando de vacío a lleno.
*
* `density` es la fracción de celdas que se convierten en cita (0..1).
*/
export function CalendarGridVisual({
className = "",
density = 0.42,
}: {
className?: string;
density?: number;
}) {
const reduced = useReducedMotion();
const total = COLS * ROWS;
// Patrón determinista: un pseudo-aleatorio con semilla fija da la misma agenda
// en cada render y en cada corrida del audit visual, que si no capturaría
// pantallas distintas cada vez y reportaría diferencias falsas.
const celdas = Array.from({ length: total }, (_, i) => {
const h = ((i * 2654435761) % 1000) / 1000;
const ocupada = h < density;
return {
ocupada,
tinte: TINTES[i % TINTES.length],
alto: h < density * 0.35 ? 2 : 1,
};
});
return (
<div className={`select-none ${className}`} aria-hidden="true">
<div className="mb-2 grid grid-cols-7 gap-1.5">
{DIAS.map((d, i) => (
<div
key={`${d}-${i}`}
className="text-center text-[0.6rem] font-bold uppercase tracking-widest opacity-40"
>
{d}
</div>
))}
</div>
<motion.div
className="grid grid-cols-7 gap-1.5"
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={{
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : 0.012 } },
}}
>
{celdas.map((c, i) => (
<motion.div
key={i}
className="h-5 rounded-[0.3rem] sm:h-6"
style={{
background: c.ocupada ? c.tinte : "currentColor",
opacity: c.ocupada ? 1 : 0.07,
}}
variants={{
hidden: reduced
? { scaleY: 1, opacity: c.ocupada ? 1 : 0.07 }
: { scaleY: 0.25, opacity: 0 },
show: {
scaleY: 1,
opacity: c.ocupada ? 1 : 0.07,
transition: { duration: 0.45, ease: EASE_EXPO },
},
}}
/>
))}
</motion.div>
</div>
);
}
```
- [ ] **Step 3: Crear `src/components/landing/HeroSection.tsx`**
```tsx
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRight, ChevronDown } from "lucide-react";
import { EASE_EXPO, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
const LINEAS = ["Tu negocio te habla", "todos los días.", "Nadie está escuchando."];
export function HeroSection() {
const reduced = useReducedMotion();
return (
<section
data-ld-section
className="landing-dark relative isolate flex min-h-screen-safe flex-col justify-center overflow-hidden ld-section"
>
{/* Fondo: la agenda del producto, desenfocada. Es el sujeto de la foto, no
un adorno — por eso es la retícula real y no un gradiente. */}
<div className="pointer-events-none absolute inset-0 -z-10 flex items-center justify-center">
<div className="w-[150%] max-w-none opacity-[0.14] blur-[2px] sm:w-[110%]">
<CalendarGridVisual density={0.5} />
</div>
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse at 50% 40%, rgba(8,8,10,0.25) 0%, rgba(8,8,10,0.92) 68%, #08080a 100%)",
}}
/>
</div>
<motion.div
className="ld-wrap"
initial="hidden"
animate="show"
variants={revealStagger(reduced, 0.08)}
>
<motion.p
className="ld-eyebrow mb-6"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced, 12)}
>
Business intelligence para tu negocio
</motion.p>
<h1 data-ld-hero-title className="ld-display max-w-4xl">
{LINEAS.map((linea, i) => (
<motion.span
key={linea}
className="block"
variants={revealUp(reduced, 28)}
style={i === 2 ? { color: "var(--ld-muted-dark)" } : undefined}
>
{linea}
</motion.span>
))}
</h1>
<motion.p
className="ld-lead mt-8 max-w-xl"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced)}
>
Cada cita, cada cancelación y cada cliente que no volvió es un dato. AgendaMax los
convierte en decisiones que te dejan dinero.
</motion.p>
<motion.div
className="mt-10 grid grid-cols-1 gap-3 sm:flex sm:items-center"
variants={revealUp(reduced)}
>
<Link
to="/login"
className="inline-flex min-h-[52px] items-center justify-center gap-2 rounded-full bg-white px-7 text-[0.95rem] font-bold text-[#08080a] transition-transform hover:scale-[1.02]"
>
Entrar a la demo
<ArrowRight className="h-4 w-4" />
</Link>
<a
href="#costo"
className="inline-flex min-h-[52px] items-center justify-center gap-2 rounded-full border px-7 text-[0.95rem] font-semibold"
style={{ borderColor: "var(--ld-hairline)", color: "var(--ld-paper)" }}
>
Ver cómo funciona
<ChevronDown className="h-4 w-4" />
</a>
</motion.div>
<motion.p
className="mt-6 text-xs"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced, 8)}
>
Sin registro. Sin tarjeta. Cuentas ya cargadas.
</motion.p>
</motion.div>
{/* Indicador de scroll con respiración lenta. Se apaga con movimiento reducido. */}
{!reduced && (
<motion.div
className="absolute bottom-8 left-1/2 -translate-x-1/2"
animate={{ y: [0, 8, 0], opacity: [0.35, 0.8, 0.35] }}
transition={{ duration: 2.4, repeat: Infinity, ease: EASE_EXPO }}
>
<ChevronDown className="h-5 w-5" />
</motion.div>
)}
</section>
);
}
```
- [ ] **Step 4: Crear `src/pages/LandingPage.tsx`**
Por ahora solo el hero. Las tareas 5-7 van añadiendo secciones a este archivo, en este orden.
```tsx
import { HeroSection } from "../components/landing/HeroSection";
/**
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
* secciones, que no reciben props ni comparten estado. Export default porque
* `React.lazy` en App.tsx lo exige.
*/
export default function LandingPage() {
return (
<div className="landing">
<HeroSection />
</div>
);
}
```
- [ ] **Step 5: Verificar tipos**
Run: `npm.cmd run typecheck`
Expected: sin errores.
- [ ] **Step 6: Correr el test de aceptación**
Run: `npm.cmd run test:landing`
Expected: pasan «`/` monta la landing», «`/` no expone el formulario de login», «la landing no lanza errores de runtime», y las de redirección y movimiento reducido. **Falla** «la landing tiene las 7 secciones del funnel» con `1 !== 7`. Ese es el estado correcto: quedan seis secciones por construir.
---
### Task 4: Visuales animados restantes
Cuatro componentes de presentación pura. Se construyen juntos porque comparten el mismo contrato (cero estado, `className` opcional, animan al entrar en viewport, se congelan con movimiento reducido) y ninguna sección posterior sirve sin ellos.
**Files:**
- Create: `src/components/landing/visuals/RevenueLineVisual.tsx`
- Create: `src/components/landing/visuals/TeamLoadVisual.tsx`
- Create: `src/components/landing/visuals/NotebookVisual.tsx`
- Create: `src/components/landing/visuals/DashboardMockVisual.tsx`
**Interfaces:**
- Consumes: `EASE_EXPO`, `inView` de `src/lib/motion.ts`; `useReducedMotion`; `CalendarGridVisual` (Tarea 3).
- Produces:
- `RevenueLineVisual({ className?: string })`
- `TeamLoadVisual({ className?: string })`
- `NotebookVisual({ className?: string })`
- `DashboardMockVisual({ className?: string })`
- [ ] **Step 1: Crear `RevenueLineVisual.tsx`**
```tsx
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
// 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.
const SERIE = [38, 44, 41, 52, 58, 54, 66, 71, 68, 79, 86, 94];
const W = 520;
const H = 200;
const PAD = 12;
function pathFrom(serie: number[]): string {
const max = Math.max(...serie);
const min = Math.min(...serie);
const dx = (W - PAD * 2) / (serie.length - 1);
return serie
.map((v, i) => {
const x = PAD + i * dx;
const y = H - PAD - ((v - min) / (max - min)) * (H - PAD * 2);
return `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(" ");
}
/** Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. */
export function RevenueLineVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
const d = pathFrom(SERIE);
const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className={`w-full ${className}`}
role="img"
aria-label="Ingresos mensuales en tendencia ascendente"
>
<defs>
<linearGradient id="ld-rev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b66ff" stopOpacity="0.28" />
<stop offset="100%" stopColor="#3b66ff" stopOpacity="0" />
</linearGradient>
</defs>
{/* Rejilla base: cuatro guías, muy tenues. Dan escala sin competir. */}
{[0.25, 0.5, 0.75].map((f) => (
<line
key={f}
x1={PAD}
x2={W - PAD}
y1={PAD + f * (H - PAD * 2)}
y2={PAD + f * (H - PAD * 2)}
stroke="currentColor"
strokeOpacity="0.08"
strokeWidth="1"
/>
))}
<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
d={d}
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
cx={W - PAD}
cy={
H -
PAD -
((SERIE[SERIE.length - 1] - Math.min(...SERIE)) /
(Math.max(...SERIE) - Math.min(...SERIE))) *
(H - PAD * 2)
}
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>
);
}
```
- [ ] **Step 2: Crear `TeamLoadVisual.tsx`**
```tsx
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
// Nombres del negocio demo sembrado (plantilla estetica-spa).
const EQUIPO = [
{ nombre: "Valentina", carga: 0.92, color: "#3b66ff", citas: 34 },
{ nombre: "Mateo", carga: 0.74, color: "#0ea5e9", citas: 27 },
{ nombre: "Camila", carga: 0.61, color: "#8b5cf6", citas: 22 },
{ nombre: "Diego", carga: 0.38, color: "#f17616", citas: 14 },
{ nombre: "Renata", carga: 0.22, color: "#10b981", citas: 8 },
];
/** Carga real por especialista. El punto: la diferencia se ve de un golpe. */
export function TeamLoadVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div className={`grid grid-cols-1 gap-3.5 ${className}`}>
{EQUIPO.map((p, i) => (
<div key={p.nombre} className="grid grid-cols-1 gap-1.5">
<div className="flex items-baseline justify-between text-sm">
<span className="font-bold">{p.nombre}</span>
<span className="ld-num text-xs opacity-55">{p.citas} citas</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-current/10">
<motion.div
className="h-full rounded-full"
style={{ background: p.color, originX: 0 }}
initial={{ scaleX: reduced ? p.carga : 0 }}
whileInView={{ scaleX: p.carga }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.9,
delay: reduced ? 0 : i * 0.09,
ease: EASE_EXPO,
}}
/>
</div>
</div>
))}
</div>
);
}
```
Nota: `bg-current/10` requiere que el contenedor herede el color del texto de la sección. Si Tailwind 3.4 no resuelve la opacidad sobre `currentColor` en tu versión, sustitúyelo por `style={{ background: "currentColor", opacity: 0.1 }}` en un div envolvente.
- [ ] **Step 3: Crear `NotebookVisual.tsx`**
```tsx
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
const APUNTES = [
{ texto: "10:00 Sra. Ortiz — tinte", tachado: true },
{ texto: "11:30 ¿Camila o Mateo?", tachado: false },
{ texto: "12:00 confirmar x WhatsApp", tachado: true },
{ texto: "1:00 —— cancelado", tachado: true },
{ texto: "4:00 pendiente $$$", tachado: false },
{ texto: "cobrar a la Sra. del jueves", tachado: false },
];
/**
* El "antes": el cuaderno. Se dibuja con tipografía manuscrita del sistema y
* tachones que se trazan, porque un icono de libreta no transmite el desorden.
*/
export function NotebookVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div
className={`relative overflow-hidden rounded-2xl bg-[#faf7ef] p-6 text-[#3b3a35] shadow-[0_24px_60px_-24px_rgba(0,0,0,0.55)] ${className}`}
aria-hidden="true"
>
{/* Renglones del cuaderno */}
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "repeating-linear-gradient(#faf7ef 0 30px, #ddd6c4 30px 31px)",
}}
/>
{/* Margen rojo */}
<div className="pointer-events-none absolute inset-y-0 left-10 w-px bg-[#d98b8b]" />
<div className="relative grid grid-cols-1 gap-[0.72rem] pl-8">
{APUNTES.map((a, i) => (
<div key={a.texto} className="relative w-fit max-w-full">
<span
className="text-[0.92rem] leading-[1.6]"
style={{ fontFamily: "'Bradley Hand', 'Segoe Script', cursive" }}
>
{a.texto}
</span>
{a.tachado && (
<motion.span
className="absolute left-0 top-1/2 h-[1.5px] w-full bg-[#3b3a35]"
style={{ originX: 0 }}
initial={{ scaleX: reduced ? 1 : 0 }}
whileInView={{ scaleX: 1 }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.4,
delay: reduced ? 0 : 0.3 + i * 0.14,
ease: EASE_EXPO,
}}
/>
)}
</div>
))}
</div>
</div>
);
}
```
- [ ] **Step 4: Crear `DashboardMockVisual.tsx`**
```tsx
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
import { RevenueLineVisual } from "./RevenueLineVisual";
const KPIS = [
{ etiqueta: "Ingresos del mes", valor: 184300, prefijo: "$", sufijo: "" },
{ etiqueta: "Citas atendidas", valor: 412, prefijo: "", sufijo: "" },
{ etiqueta: "Ocupación", valor: 78, prefijo: "", sufijo: "%" },
];
const TOP = [
{ nombre: "Corte + barba", monto: 42800 },
{ nombre: "Tinte completo", monto: 38150 },
{ nombre: "Manicura gel", monto: 24600 },
];
/** Contador que interpola de 0 al valor. Se salta la animación si hay reduced motion. */
function Contador({ valor, prefijo, sufijo }: { valor: number; prefijo: string; sufijo: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-10% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible) return;
const DUR = 1400;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
// ease-out cúbico: llega rápido y frena, igual que EASE_EXPO en CSS.
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(paso);
};
raf = requestAnimationFrame(paso);
return () => cancelAnimationFrame(raf);
}, [visible, valor, reduced]);
return (
<span ref={ref} className="ld-num">
{prefijo}
{n.toLocaleString("es-MX")}
{sufijo}
</span>
);
}
/** El panel real, a escala. Es la prueba: datos de la cuenta demo. */
export function DashboardMockVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<motion.div
className={`grid grid-cols-1 gap-4 rounded-3xl border border-black/[0.06] bg-white p-5 text-[#0f172a] shadow-[0_40px_100px_-40px_rgba(15,23,42,0.45)] sm:p-7 ${className}`}
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.8, ease: EASE_EXPO }}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{KPIS.map((k) => (
<div key={k.etiqueta} className="rounded-2xl bg-[#f6f7fb] p-4">
<div className="text-[0.7rem] font-bold uppercase tracking-wider text-slate-500">
{k.etiqueta}
</div>
<div className="mt-1.5 text-2xl font-extrabold tracking-tight sm:text-[1.75rem]">
<Contador valor={k.valor} prefijo={k.prefijo} sufijo={k.sufijo} />
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.6fr_1fr]">
<div className="rounded-2xl bg-[#f6f7fb] p-4">
<div className="mb-2 text-[0.7rem] font-bold uppercase tracking-wider text-slate-500">
Ingresos · últimos 12 meses
</div>
<RevenueLineVisual />
</div>
<div className="rounded-2xl bg-[#f6f7fb] p-4">
<div className="mb-3 text-[0.7rem] font-bold uppercase tracking-wider text-slate-500">
Servicios que más dejan
</div>
<div className="grid grid-cols-1 gap-2.5">
{TOP.map((s, i) => (
<motion.div
key={s.nombre}
className="flex items-baseline justify-between gap-3"
initial={{ opacity: reduced ? 1 : 0, x: reduced ? 0 : -12 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={inView}
transition={{
duration: 0.5,
delay: reduced ? 0 : 0.4 + i * 0.1,
ease: EASE_EXPO,
}}
>
<span className="truncate text-sm font-semibold">{s.nombre}</span>
<span className="ld-num shrink-0 text-sm font-bold text-brand-600">
${s.monto.toLocaleString("es-MX")}
</span>
</motion.div>
))}
</div>
</div>
</div>
</motion.div>
);
}
```
- [ ] **Step 5: Verificar tipos**
Run: `npm.cmd run typecheck`
Expected: sin errores. Los cuatro visuales todavía no tienen consumidores; esto solo valida sus tipos.
---
### Task 5: Secciones 2 y 3 — el costo y el cambio
**Files:**
- Create: `src/components/landing/CostSection.tsx`
- Create: `src/components/landing/ShiftSection.tsx`
- Modify: `src/pages/LandingPage.tsx`
**Interfaces:**
- Consumes: `revealUp`, `revealStagger`, `inView`, `EASE_EXPO`, `useReducedMotion`, `NotebookVisual`, `CalendarGridVisual`.
- Produces: `CostSection`, `ShiftSection` (named exports, cero props).
- [ ] **Step 1: Crear `CostSection.tsx`**
```tsx
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Cuatro fugas, no cuatro features. El copy nombra el costo de no saber; el
* producto no se menciona todavía. Vender antes de haber dolido no funciona.
*/
const FUGAS = [
{
cifra: 34,
sufijo: "%",
titulo: "de tus horas se van vacías",
detalle:
"Huecos de 40 minutos entre citas que nadie llena porque nadie los ve. Son sueldo pagado sin ingreso.",
},
{
cifra: 5,
sufijo: " meses",
titulo: "lleva sin volver tu cliente frecuente",
detalle:
"Sigue en tu lista. Nadie te avisó. Recuperarlo cuesta una fracción de conseguir uno nuevo.",
},
{
cifra: 1,
sufijo: " de 6",
titulo: "servicios te cuesta más de lo que cobra",
detalle:
"Entre tiempo de silla, insumos y comisión, hay uno que trabaja gratis. Sin desglose no aparece.",
},
{
cifra: 0,
sufijo: "",
titulo: "datos tienes de quién sostiene el negocio",
detalle:
"Sabes quién es tu mejor especialista por corazonada. Cuando se va, te enteras del tamaño real.",
},
];
function Cifra({ valor, sufijo }: { valor: number; sufijo: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-15% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible || valor === 0) {
if (valor === 0) setN(0);
return;
}
const DUR = 1100;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(paso);
};
raf = requestAnimationFrame(paso);
return () => cancelAnimationFrame(raf);
}, [visible, valor, reduced]);
return (
<span ref={ref} className="ld-num">
{n}
{sufijo}
</span>
);
}
export function CostSection() {
const reduced = useReducedMotion();
return (
<section id="costo" data-ld-section className="landing-light ld-section">
<div className="ld-wrap">
<motion.div
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 12)}
>
El costo de no saber
</motion.p>
<motion.h2 className="ld-title max-w-3xl" variants={revealUp(reduced, 24)}>
Lo que no mides no te avisa. Te cobra.
</motion.h2>
<motion.p
className="ld-lead mt-6 max-w-xl"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Ninguna de estas cuatro fugas aparece en tu cuenta de banco con nombre y apellido. Todas
salen de ahí.
</motion.p>
</motion.div>
<div className="mt-16 grid grid-cols-1 gap-x-10 gap-y-12 sm:grid-cols-2">
{FUGAS.map((f, i) => (
<motion.div
key={f.titulo}
className="grid grid-cols-1 gap-3 border-t pt-7"
style={{ borderColor: "rgba(8,8,10,0.12)" }}
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.7, delay: reduced ? 0 : i * 0.08 }}
>
<div className="text-5xl font-extrabold tracking-tight text-brand-600 sm:text-6xl">
<Cifra valor={f.cifra} sufijo={f.sufijo} />
</div>
<h3 className="text-lg font-extrabold leading-snug">{f.titulo}</h3>
<p className="text-[0.95rem] leading-relaxed" style={{ color: "var(--ld-muted)" }}>
{f.detalle}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}
```
- [ ] **Step 2: Crear `ShiftSection.tsx`**
```tsx
import { useRef } from "react";
import { motion, useScroll, useTransform } from "framer-motion";
import { revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { NotebookVisual } from "./visuals/NotebookVisual";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
/**
* El cambio, contado con el scroll: el cuaderno se va y el tablero llega. Es la
* única sección con scrub (progreso ligado a la posición), que es lo que hace que
* el scroll se sienta como una película y no como una lista.
*
* El contenedor mide 260vh para que el panel sticky tenga recorrido; con
* movimiento reducido se colapsa a una pila estática de altura natural.
*/
export function ShiftSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start start", "end end"],
});
const notaOpacidad = useTransform(scrollYProgress, [0.05, 0.42], [1, 0]);
const notaEscala = useTransform(scrollYProgress, [0.05, 0.42], [1, 0.92]);
const notaGiro = useTransform(scrollYProgress, [0.05, 0.42], [-2, -7]);
const panelOpacidad = useTransform(scrollYProgress, [0.32, 0.62], [0, 1]);
const panelY = useTransform(scrollYProgress, [0.32, 0.62], [40, 0]);
if (reduced) {
return (
<section data-ld-section className="landing-dark ld-section">
<div className="ld-wrap grid grid-cols-1 gap-12">
<div>
<p className="ld-eyebrow mb-5" style={{ color: "var(--ld-muted-dark)" }}>
El cambio
</p>
<h2 className="ld-title max-w-3xl">
No es que trabajes poco. Es que trabajas sin instrumentos.
</h2>
<p className="ld-lead mt-6 max-w-xl" style={{ color: "var(--ld-muted-dark)" }}>
El mismo día, los mismos clientes, el mismo equipo. La diferencia es que ahora lo ves.
</p>
</div>
<NotebookVisual className="max-w-md" />
<div className="rounded-2xl border p-6" style={{ borderColor: "var(--ld-hairline)" }}>
<CalendarGridVisual />
</div>
</div>
</section>
);
}
return (
<section data-ld-section className="landing-dark">
<div ref={ref} className="relative h-[260vh]">
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={revealUp(false)}
>
<p className="ld-eyebrow mb-5" style={{ color: "var(--ld-muted-dark)" }}>
El cambio
</p>
<h2 className="ld-title">
No es que trabajes poco.
<br />
<span style={{ color: "var(--ld-muted-dark)" }}>
Es que trabajas sin instrumentos.
</span>
</h2>
<p className="ld-lead mt-6 max-w-md" style={{ color: "var(--ld-muted-dark)" }}>
El mismo día, los mismos clientes, el mismo equipo. La diferencia es que ahora lo
ves.
</p>
</motion.div>
{/* Escenario: las dos piezas ocupan la misma caja y se relevan. */}
<div className="relative min-h-[22rem] sm:min-h-[26rem]">
<motion.div
className="absolute inset-0"
style={{ opacity: notaOpacidad, scale: notaEscala, rotate: notaGiro }}
>
<NotebookVisual />
</motion.div>
<motion.div
className="absolute inset-0 rounded-2xl border p-5 sm:p-6"
style={{
opacity: panelOpacidad,
y: panelY,
borderColor: "var(--ld-hairline)",
background: "rgba(255,255,255,0.03)",
}}
>
<div className="mb-3 text-[0.7rem] font-bold uppercase tracking-wider opacity-50">
Tu semana, completa
</div>
<CalendarGridVisual />
</motion.div>
</div>
</div>
</div>
</div>
</section>
);
}
```
- [ ] **Step 3: Montar las dos secciones en `LandingPage.tsx`**
```tsx
import { HeroSection } from "../components/landing/HeroSection";
import { CostSection } from "../components/landing/CostSection";
import { ShiftSection } from "../components/landing/ShiftSection";
/**
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
* secciones, que no reciben props ni comparten estado. Export default porque
* `React.lazy` en App.tsx lo exige.
*/
export default function LandingPage() {
return (
<div className="landing">
<HeroSection />
<CostSection />
<ShiftSection />
</div>
);
}
```
- [ ] **Step 4: Verificar tipos**
Run: `npm.cmd run typecheck`
Expected: sin errores.
- [ ] **Step 5: Correr el test**
Run: `npm.cmd run test:landing`
Expected: sigue fallando «7 secciones» con `3 !== 7`. **Deben pasar** las tres comprobaciones de movimiento reducido — si «con movimiento reducido todas las secciones son visibles» falla aquí, hay un `initial` sin rama `reduced` en `CostSection` o `ShiftSection`. Arréglalo antes de continuar; es el bug que este test existe para atrapar.
---
### Task 6: Secciones 4 y 5 — el producto y la prueba
**Files:**
- Create: `src/components/landing/ProductSection.tsx`
- Create: `src/components/landing/ProofSection.tsx`
- Modify: `src/pages/LandingPage.tsx`
**Interfaces:**
- Consumes: `EASE_EXPO` (solo `ProductSection`), `inView` + `revealStagger` + `revealUp` (solo `ProofSection`), `useReducedMotion`, `CalendarGridVisual`, `RevenueLineVisual`, `TeamLoadVisual`, `DashboardMockVisual`.
- Produces: `ProductSection`, `ProofSection` (named exports, cero props).
- [ ] **Step 1: Crear `ProductSection.tsx`**
```tsx
import { useRef, useState } from "react";
import { motion, useMotionValueEvent, useScroll } from "framer-motion";
import { CalendarDays, LineChart, Users } from "lucide-react";
import { EASE_EXPO } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
import { RevenueLineVisual } from "./visuals/RevenueLineVisual";
import { TeamLoadVisual } from "./visuals/TeamLoadVisual";
const ACTOS = [
{
icono: CalendarDays,
rotulo: "Agenda",
titulo: "Tu día completo en una pantalla.",
copy: "Arrastra una cita y se reacomoda. El sistema no te deja agendar dos personas en el mismo lugar a la misma hora, ni asignar a quien no está en turno.",
visual: <CalendarGridVisual density={0.48} />,
},
{
icono: LineChart,
rotulo: "Inteligencia",
titulo: "Los números que tu contador te da en marzo, hoy a las 3 de la tarde.",
copy: "Ingresos por día, por servicio y por especialista. Qué creció, qué cayó y desde cuándo. Sin exportar nada, sin pedirle un reporte a nadie.",
visual: <RevenueLineVisual />,
},
{
icono: Users,
rotulo: "Equipo",
titulo: "Deja de repartir el trabajo por intuición.",
copy: "La carga real de cada especialista, lado a lado. Quién está saturado, quién tiene hueco y quién trae de vuelta a los clientes.",
visual: <TeamLoadVisual />,
},
];
export function ProductSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const [activo, setActivo] = useState(0);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
useMotionValueEvent(scrollYProgress, "change", (p) => {
// Tres tramos iguales. Se clampea al último para que el final del recorrido no
// devuelva un índice fuera de rango.
setActivo(Math.min(ACTOS.length - 1, Math.floor(p * ACTOS.length)));
});
// Con movimiento reducido no hay panel sticky: los tres actos se apilan enteros.
if (reduced) {
return (
<section data-ld-section className="landing-light ld-section">
<div className="ld-wrap grid grid-cols-1 gap-20">
{ACTOS.map((a) => (
<div key={a.rotulo} className="grid grid-cols-1 items-center gap-8 lg:grid-cols-2">
<div>
<p className="ld-eyebrow mb-4 text-brand-600">{a.rotulo}</p>
<h3 className="ld-title">{a.titulo}</h3>
<p className="ld-lead mt-5" style={{ color: "var(--ld-muted)" }}>
{a.copy}
</p>
</div>
<div className="rounded-2xl border border-black/[0.07] bg-white p-6">{a.visual}</div>
</div>
))}
</div>
</section>
);
}
return (
<section data-ld-section className="landing-light">
<div ref={ref} className="relative" style={{ height: `${ACTOS.length * 100}vh` }}>
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
{/* Texto: los tres actos ocupan la misma caja y se relevan. */}
<div className="relative min-h-[19rem]">
{ACTOS.map((a, i) => {
const Icono = a.icono;
return (
<motion.div
key={a.rotulo}
className="absolute inset-0 grid grid-cols-1 content-center"
animate={{
opacity: activo === i ? 1 : 0,
y: activo === i ? 0 : 18,
}}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
<div className="mb-4 flex items-center gap-2.5 text-brand-600">
<Icono className="h-4 w-4" />
<span className="ld-eyebrow">{a.rotulo}</span>
</div>
<h3 className="ld-title">{a.titulo}</h3>
<p className="ld-lead mt-5 max-w-lg" style={{ color: "var(--ld-muted)" }}>
{a.copy}
</p>
</motion.div>
);
})}
</div>
{/* Panel: cambia de visual con el acto. */}
<div className="relative min-h-[20rem] rounded-3xl border border-black/[0.07] bg-white p-5 shadow-[0_30px_80px_-40px_rgba(15,23,42,0.35)] sm:min-h-[24rem] sm:p-7">
{ACTOS.map((a, i) => (
<motion.div
key={a.rotulo}
className="absolute inset-5 grid grid-cols-1 content-center sm:inset-7"
animate={{
opacity: activo === i ? 1 : 0,
scale: activo === i ? 1 : 0.97,
}}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
{a.visual}
</motion.div>
))}
</div>
</div>
</div>
</div>
{/* Indicador de progreso entre actos. */}
<div className="ld-wrap flex items-center justify-center gap-2 pb-6">
{ACTOS.map((a, i) => (
<motion.span
key={a.rotulo}
className="h-1 rounded-full bg-current"
animate={{ width: activo === i ? 28 : 10, opacity: activo === i ? 0.8 : 0.2 }}
transition={{ duration: 0.4, ease: EASE_EXPO }}
/>
))}
</div>
</section>
);
}
```
Nota: esta sección no usa `inView` ni `revealUp` a propósito — el scrub por scroll reemplaza al reveal
por viewport. `EASE_EXPO` es lo único que necesita de `motion.ts`.
- [ ] **Step 2: Crear `ProofSection.tsx`**
```tsx
import { motion } from "framer-motion";
import { ShieldCheck } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { DashboardMockVisual } from "./visuals/DashboardMockVisual";
export function ProofSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-light ld-section pt-0">
<div className="ld-wrap">
<motion.div
className="mx-auto max-w-2xl text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5 text-brand-600"
variants={revealUp(reduced, 12)}
>
La prueba
</motion.p>
<motion.h2 className="ld-title" variants={revealUp(reduced, 24)}>
Esto no es una maqueta.
</motion.h2>
<motion.p
className="ld-lead mt-6"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Es el tablero de la cuenta demo, con datos reales de un negocio sembrado. Puedes entrar,
mover una cita y ver cómo cambian los números.
</motion.p>
</motion.div>
<DashboardMockVisual className="mt-14" />
<motion.div
className="mx-auto mt-8 flex max-w-xl items-start gap-3 text-sm"
style={{ color: "var(--ld-muted)" }}
initial={{ opacity: reduced ? 1 : 0 }}
whileInView={{ opacity: 1 }}
viewport={inView}
transition={{ duration: 0.6, delay: reduced ? 0 : 0.3 }}
>
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-600" />
<span>
Entorno de demostración. Los datos se pueden reiniciar en cualquier momento, así que
muévelos sin miedo.
</span>
</motion.div>
</div>
</section>
);
}
```
- [ ] **Step 3: Montar en `LandingPage.tsx`**
Añade los dos imports y las dos etiquetas después de `<ShiftSection />`:
```tsx
import { HeroSection } from "../components/landing/HeroSection";
import { CostSection } from "../components/landing/CostSection";
import { ShiftSection } from "../components/landing/ShiftSection";
import { ProductSection } from "../components/landing/ProductSection";
import { ProofSection } from "../components/landing/ProofSection";
export default function LandingPage() {
return (
<div className="landing">
<HeroSection />
<CostSection />
<ShiftSection />
<ProductSection />
<ProofSection />
</div>
);
}
```
- [ ] **Step 4: Verificar tipos**
Run: `npm.cmd run typecheck`
Expected: sin errores.
- [ ] **Step 5: Correr el test**
Run: `npm.cmd run test:landing`
Expected: «7 secciones» falla con `5 !== 7`. Todo lo demás en verde.
---
### Task 7: Secciones 6 y 7 — objeciones, cierre y nav
**Files:**
- Create: `src/components/landing/ObjectionsSection.tsx`
- Create: `src/components/landing/ClosingSection.tsx`
- Create: `src/components/landing/LandingNav.tsx`
- Modify: `src/pages/LandingPage.tsx`
**Interfaces:**
- Consumes: `inView`, `revealStagger`, `revealUp`, `EASE_EXPO`, `useReducedMotion`.
- Produces: `ObjectionsSection`, `ClosingSection`, `LandingNav` (named exports, cero props). `ClosingSection` debe rendir el atributo `data-ld-closing-cta` en su CTA.
- [ ] **Step 1: Crear `ObjectionsSection.tsx`**
```tsx
import { motion } from "framer-motion";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/** Las tres objeciones reales de un dueño de negocio en LATAM, sin endulzar. */
const OBJECIONES = [
{
dice: "No soy de tecnología.",
responde:
"Si sabes usar WhatsApp, sabes usar esto. Se arrastra con el dedo y no hay nada que configurar antes de empezar a usarlo.",
},
{
dice: "Mi equipo no lo va a adoptar.",
responde:
"Cada empleado ve solo su día y sus propios números. No administra nada, no configura nada. Es menos trabajo que preguntarte a ti a qué hora entra.",
},
{
dice: "Ya tengo mi cuaderno y me funciona.",
responde:
"Tu cuaderno te dice a qué hora es la cita. No te dice qué servicio te está costando dinero, ni quién de tus clientes dejó de venir hace cuatro meses.",
},
];
export function ObjectionsSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-light ld-section">
<div className="ld-wrap">
<motion.div
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 12)}
>
Lo que estás pensando
</motion.p>
<motion.h2 className="ld-title max-w-2xl" variants={revealUp(reduced, 24)}>
Tres razones para no hacerlo. Y por qué ninguna aguanta.
</motion.h2>
</motion.div>
<div className="mt-14 grid grid-cols-1 gap-px overflow-hidden rounded-2xl bg-black/[0.09]">
{OBJECIONES.map((o, i) => (
<motion.div
key={o.dice}
className="grid grid-cols-1 gap-3 bg-[var(--ld-paper)] p-7 sm:grid-cols-[1fr_1.5fr] sm:gap-10 sm:p-9"
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.65, delay: reduced ? 0 : i * 0.1 }}
>
<p className="text-xl font-extrabold leading-snug tracking-tight sm:text-2xl">
«{o.dice}»
</p>
<p className="text-[0.98rem] leading-relaxed" style={{ color: "var(--ld-muted)" }}>
{o.responde}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}
```
- [ ] **Step 2: Crear `ClosingSection.tsx`**
```tsx
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRight, Sparkles } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
export function ClosingSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-dark relative overflow-hidden">
{/* Cierra el círculo del hero: la misma retícula, ahora llena. */}
<div className="pointer-events-none absolute inset-0 -z-10 flex items-center justify-center">
<div className="w-[150%] max-w-none opacity-[0.1] blur-[2px] sm:w-[110%]">
<CalendarGridVisual density={0.72} />
</div>
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse at 50% 50%, rgba(8,8,10,0.35) 0%, rgba(8,8,10,0.94) 70%, #08080a 100%)",
}}
/>
</div>
<motion.div
className="ld-wrap ld-section text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.h2 className="ld-display mx-auto max-w-3xl" variants={revealUp(reduced, 28)}>
Deja de adivinar.
</motion.h2>
<motion.p
className="ld-lead mx-auto mt-7 max-w-lg"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced)}
>
Entra a la demo con una cuenta ya cargada y mira tu negocio como se ve cuando tienes los
números enfrente.
</motion.p>
<motion.div className="mt-11" variants={revealUp(reduced)}>
<Link
data-ld-closing-cta
to="/login"
className="inline-flex min-h-[56px] items-center justify-center gap-2 rounded-full bg-white px-9 text-base font-bold text-[#08080a] transition-transform hover:scale-[1.02]"
>
Entrar a la demo
<ArrowRight className="h-4 w-4" />
</Link>
</motion.div>
<motion.p
className="mt-6 text-xs"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced, 8)}
>
Sin registro. Sin tarjeta. Cuentas ya cargadas.
</motion.p>
</motion.div>
<footer
className="ld-wrap flex flex-col items-center justify-between gap-4 border-t px-5 py-8 text-xs sm:flex-row"
style={{ borderColor: "var(--ld-hairline)", color: "var(--ld-muted-dark)" }}
>
<div className="flex items-center gap-2 font-bold">
<Sparkles className="h-4 w-4" />
AgendaMax
</div>
<span>© {new Date().getFullYear()} AgendaMax · Entorno de demostración</span>
<Link to="/login" className="font-semibold underline-offset-4 hover:underline">
Iniciar sesión
</Link>
</footer>
</section>
);
}
```
- [ ] **Step 3: Crear `LandingNav.tsx`**
```tsx
import { Link } from "react-router-dom";
import { motion, useScroll, useTransform } from "framer-motion";
import { Sparkles } from "lucide-react";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Nav flotante. Arranca transparente sobre el hero y se condensa al scrollear:
* fondo y desenfoque suben juntos, que es lo que evita el salto brusco de un
* `position: fixed` con fondo sólido desde el primer píxel.
*/
export function LandingNav() {
const reduced = useReducedMotion();
const { scrollY } = useScroll();
const fondo = useTransform(scrollY, [0, 140], ["rgba(8,8,10,0)", "rgba(8,8,10,0.72)"]);
const desenfoque = useTransform(scrollY, [0, 140], ["blur(0px)", "blur(14px)"]);
const borde = useTransform(scrollY, [0, 140], ["rgba(255,255,255,0)", "rgba(255,255,255,0.09)"]);
return (
<motion.header
className="safe-top fixed inset-x-0 top-0 z-50 border-b"
style={
reduced
? { background: "rgba(8,8,10,0.82)", borderColor: "rgba(255,255,255,0.09)" }
: { background: fondo, backdropFilter: desenfoque, borderColor: borde }
}
>
<nav className="ld-wrap flex items-center justify-between gap-4 px-5 py-3.5">
<Link
to="/"
className="flex items-center gap-2 text-[0.95rem] font-extrabold tracking-tight text-white"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/12">
<Sparkles className="h-4 w-4" />
</span>
AgendaMax
</Link>
<Link
to="/login"
className="tap-target inline-flex items-center rounded-full bg-white px-5 text-sm font-bold text-[#08080a]"
>
Entrar a la demo
</Link>
</nav>
</motion.header>
);
}
```
- [ ] **Step 4: Completar `LandingPage.tsx`**
```tsx
import { LandingNav } from "../components/landing/LandingNav";
import { HeroSection } from "../components/landing/HeroSection";
import { CostSection } from "../components/landing/CostSection";
import { ShiftSection } from "../components/landing/ShiftSection";
import { ProductSection } from "../components/landing/ProductSection";
import { ProofSection } from "../components/landing/ProofSection";
import { ObjectionsSection } from "../components/landing/ObjectionsSection";
import { ClosingSection } from "../components/landing/ClosingSection";
/**
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
* secciones, que no reciben props ni comparten estado. Export default porque
* `React.lazy` en App.tsx lo exige.
*
* Orden del funnel: problema → costo → mecanismo → prueba → objeción → cierre.
* `LandingNav` es chrome fijo, no cuenta como sección.
*/
export default function LandingPage() {
return (
<div className="landing">
<LandingNav />
<HeroSection />
<CostSection />
<ShiftSection />
<ProductSection />
<ProofSection />
<ObjectionsSection />
<ClosingSection />
</div>
);
}
```
- [ ] **Step 5: Verificar tipos**
Run: `npm.cmd run typecheck`
Expected: sin errores.
- [ ] **Step 6: Correr el test**
Run: `npm.cmd run test:landing`
Expected: «la landing tiene las 7 secciones del funnel» ahora pasa. **Falla** «`/login` ofrece al menos 3 cuentas de acceso mágico» y «un clic en la tarjeta de dueña entra al panel», porque `LoginPage` todavía no tiene `[data-magic-login]`. Verifica también «la landing no desborda en horizontal a 390px»: si falla, el culpable más probable es un `w-[150%]` de fondo sin `overflow-hidden` en su sección.
---
### Task 8: Login mágico
**Files:**
- Modify: `src/pages/LoginPage.tsx` (reescritura completa)
**Interfaces:**
- Consumes: `useAuth` de `src/lib/auth`; `api.auth.demoUsers()` de `src/lib/api`; `Spinner` de `src/components/ui`; `InstallAppPrompt`; `EASE_EXPO`, `revealStagger`, `revealUp` de `src/lib/motion`; `useReducedMotion`.
- Produces: `LoginPage` (named export, como hoy). Contrato de DOM que los cuatro audits dependen:
- `input[type="email"]`, `input[type="password"]`, `button[type="submit"]` **visibles en la carga inicial**, sin interacción.
- `[data-magic-login]` por tarjeta; `[data-magic-login][data-role="owner"]` para la dueña.
- [ ] **Step 1: Reescribir `src/pages/LoginPage.tsx`**
```tsx
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AlertCircle, ArrowLeft, ArrowRight, Lock, Mail, Sparkles, Wand2 } from "lucide-react";
import { motion } from "framer-motion";
import { useAuth } from "../lib/auth";
import { api } from "../lib/api";
import { Spinner } from "../components/ui";
import { InstallAppPrompt } from "../components/InstallAppPrompt";
import { revealStagger, revealUp } from "../lib/motion";
import { useReducedMotion } from "../lib/useReducedMotion";
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
type DemoUser = { email: string; name: string; role: string; avatar_color: string };
/** Qué desbloquea cada rol. Es la información que convierte una lista de correos
* en una decisión: el visitante elige qué producto quiere ver. */
const GRUPOS = [
{
role: "owner",
titulo: "Dueña del negocio",
detalle: "Tablero completo: ingresos, equipo, clientes y caja.",
limite: 1,
},
{
role: "employee",
titulo: "Especialista",
detalle: "Solo su agenda del día y su propio desempeño.",
limite: 2,
},
{
role: "admin",
titulo: "Plataforma",
detalle: "Consola multi-negocio: alta, reset y métricas de todos los tenants.",
limite: 1,
},
] as const;
export function LoginPage() {
const { login, user } = useAuth();
const navigate = useNavigate();
const reduced = useReducedMotion();
const [email, setEmail] = useState(DEMO ? "[email protected]" : "");
const [password, setPassword] = useState(DEMO ? "demo1234" : "");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [pendiente, setPendiente] = useState<string | null>(null);
const [demoUsers, setDemoUsers] = useState<DemoUser[]>([]);
useEffect(() => {
if (user) navigate("/", { replace: true });
}, [user, navigate]);
useEffect(() => {
if (!DEMO) return;
api.auth
.demoUsers()
.then((r) => setDemoUsers(r.users))
.catch(() => {});
}, []);
const grupos = useMemo(
() =>
GRUPOS.map((g) => ({
...g,
cuentas: demoUsers.filter((u) => u.role === g.role).slice(0, g.limite),
})).filter((g) => g.cuentas.length > 0),
[demoUsers]
);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(email, password);
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message || "No pudimos iniciar sesión");
} finally {
setLoading(false);
}
};
// Acceso mágico, solo demo. `DEMO` es una constante de build, así que esta rama
// entera —y con ella la contraseña— se elimina del bundle de producción por
// dead-code elimination. Por eso la contraseña vive aquí dentro y no arriba.
const quickLogin = DEMO
? async (mail: string) => {
setEmail(mail);
setPassword("demo1234");
setError(null);
setPendiente(mail);
setLoading(true);
try {
await login(mail, "demo1234");
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message || "No pudimos iniciar sesión");
} finally {
setLoading(false);
setPendiente(null);
}
}
: undefined;
return (
<div className="landing landing-dark relative min-h-screen-safe overflow-hidden">
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse at 20% 0%, rgba(59,102,255,0.22) 0%, rgba(8,8,10,0) 55%)",
}}
/>
<div className="safe-top safe-x relative mx-auto flex w-full max-w-5xl flex-col px-5 pb-12 pt-6">
<Link
to="/"
className="tap-target inline-flex w-fit items-center gap-2 text-sm font-semibold"
style={{ color: "var(--ld-muted-dark)" }}
>
<ArrowLeft className="h-4 w-4" />
Volver al inicio
</Link>
<motion.div
className="mt-10 grid grid-cols-1 items-start gap-10 lg:mt-14 lg:grid-cols-[1.05fr_1fr] lg:gap-14"
initial="hidden"
animate="show"
variants={revealStagger(reduced)}
>
{/* ---- Columna izquierda: acceso mágico ---- */}
<motion.div variants={revealUp(reduced)}>
<div className="flex items-center gap-2.5">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/12">
<Sparkles className="h-5 w-5" />
</span>
<span className="text-lg font-extrabold tracking-tight">AgendaMax</span>
</div>
<h1 className="ld-title mt-8 text-[clamp(1.75rem,4vw,2.5rem)]">
Entra sin registrarte.
</h1>
<p className="ld-lead mt-4 max-w-md" style={{ color: "var(--ld-muted-dark)" }}>
Elige con qué ojos quieres ver el producto. Cada cuenta abre una vista distinta del
mismo negocio.
</p>
{DEMO && (
<div className="mt-9 grid grid-cols-1 gap-7">
{grupos.map((g) => (
<div key={g.role} className="grid grid-cols-1 gap-3">
<div>
<div className="text-sm font-extrabold">{g.titulo}</div>
<div className="text-xs" style={{ color: "var(--ld-muted-dark)" }}>
{g.detalle}
</div>
</div>
<div className="grid grid-cols-1 gap-2">
{g.cuentas.map((u) => (
<button
key={u.email}
type="button"
data-magic-login
data-role={u.role}
onClick={() => quickLogin?.(u.email)}
disabled={loading}
className="group flex min-h-[56px] w-full items-center gap-3 rounded-2xl border px-4 py-2.5 text-left transition-colors hover:bg-white/[0.06] disabled:opacity-60"
style={{ borderColor: "var(--ld-hairline)" }}
>
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white"
style={{ background: u.avatar_color }}
>
{u.name
.split(" ")
.map((p) => p[0])
.slice(0, 2)
.join("")}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold">{u.name}</span>
<span
className="block truncate text-[11px]"
style={{ color: "var(--ld-muted-dark)" }}
>
{u.email}
</span>
</span>
{pendiente === u.email ? (
<Spinner />
) : (
<Wand2
className="h-4 w-4 shrink-0 opacity-40 transition-opacity group-hover:opacity-100"
/>
)}
</button>
))}
</div>
</div>
))}
</div>
)}
</motion.div>
{/* ---- Columna derecha: formulario manual ----
Visible sin interacción a propósito: visual-audit, responsive-audit y
pwa-e2e rellenan estos campos justo después del goto. Colapsarlo tras
un toggle rompería los tres. */}
<motion.div
className="w-full rounded-3xl border p-6 sm:p-7"
style={{ borderColor: "var(--ld-hairline)", background: "rgba(255,255,255,0.04)" }}
variants={revealUp(reduced)}
>
<h2 className="text-lg font-extrabold">Entrar con correo</h2>
<p className="mt-1 text-sm" style={{ color: "var(--ld-muted-dark)" }}>
Si ya tienes una cuenta del negocio.
</p>
<form onSubmit={submit} className="mt-6 grid grid-cols-1 gap-4">
<div>
<label className="label" style={{ color: "var(--ld-muted-dark)" }}>
Correo
</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="email"
className="input pl-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
autoComplete="email"
required
/>
</div>
</div>
<div>
<label className="label" style={{ color: "var(--ld-muted-dark)" }}>
Contraseña
</label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="password"
className="input pl-9"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
required
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-rose-500/15 px-3 py-2 text-sm font-medium text-rose-200">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={loading}>
{loading && !pendiente ? (
<Spinner />
) : (
<>
Entrar <ArrowRight className="h-4 w-4" />
</>
)}
</button>
</form>
<div className="mt-5">
<InstallAppPrompt />
</div>
{DEMO && (
<p className="mt-5 text-center text-xs" style={{ color: "var(--ld-muted-dark)" }}>
Demo · cualquier cuenta usa la contraseña{" "}
<code className="font-mono font-semibold text-white">demo1234</code>
</p>
)}
</motion.div>
</motion.div>
</div>
</div>
);
}
```
- [ ] **Step 2: Verificar tipos**
Run: `npm.cmd run typecheck`
Expected: sin errores.
- [ ] **Step 3: Correr el test de aceptación completo**
Run: `npm.cmd run test:landing`
Expected: **todas** las comprobaciones en verde, con `13 comprobaciones OK` al final.
- [ ] **Step 4: Revisar el login a 390px con el ojo**
Run: `npm.cmd run audit:responsive`
Expected: este comando **va a fallar** en la página `login` porque `PAGES` sigue apuntando a `/`, que ahora es la landing. Eso se arregla en la Tarea 9. Lo que se busca aquí es que el resto de páginas no haya regresionado. Anota los hallazgos de `login` y sigue.
---
### Task 9: Arreglar los audits existentes
Los tres scripts hacen `goto('/')` y a continuación `fill('input[type="email"]')`. Con la landing en `/` ese `fill` falla. El arreglo es un cambio de URL: el formulario sigue visible sin interacción previa.
**Files:**
- Modify: `visual-audit.mjs:16` y `:44`
- Modify: `responsive-audit.mjs:53` y `:264`
- Modify: `pwa-e2e.mjs:187, 200, 230, 250, 262, 280, 288`
**Interfaces:**
- Consumes: el contrato de DOM de `/login` que produjo la Tarea 8.
- Produces: nada que consuman tareas posteriores.
- [ ] **Step 1: `visual-audit.mjs` — apuntar el login a `/login` y añadir la landing**
En `PAGES` (línea 16), sustituye la entrada `login` y añade `landing` al frente:
```js
const PAGES = [
{ name: "landing", path: "/", login: false },
{ name: "login", path: "/login", login: false },
{ name: "dashboard", path: "/dashboard", login: true, role: "owner" },
```
En el helper `login()` (línea 44):
```js
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
```
- [ ] **Step 2: `responsive-audit.mjs` — lo mismo**
En `PAGES` (línea 53):
```js
const PAGES = [
// La landing es pública y no lleva sesión; se audita como cualquier otra página.
{ name: "landing", path: "/", role: null },
{ name: "login", path: "/login", role: null },
{ name: "calendar", path: "/calendar", role: "owner", waitFor: ".fc" },
```
En el helper `login()` (línea 264):
```js
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
```
- [ ] **Step 3: `pwa-e2e.mjs` — los siete `goto` que esperan el login**
`InstallAppPrompt` vive en el login y dentro del panel, no en la landing. Los siete contextos que
prueban el prompt deben aterrizar en `/login`. Ya existe el helper `url(pathname)` en la línea 12;
úsalo. Añade una constante junto a `baseUrl` (línea 5):
```js
const loginUrl = new URL("login", BASE.endsWith("/") ? BASE : `${BASE}/`).href;
```
Y sustituye `baseUrl` por `loginUrl` en estos siete `goto`:
| Línea | Contexto | Por qué |
|---|---|---|
| 187 | `chromiumPage` | Espera el botón de instalar y luego `assertInstallAction(…, "login")`. |
| 200 | `dismissalPage` | Prueba el descarte del prompt. |
| 230 | `businessPage` | Hace `login(businessPage, OWNER_EMAIL, …)` justo después. |
| 250 | `iosPage` | Verifica las instrucciones de iOS del prompt. |
| 262 | `adminPage` | Hace `login(adminPage, ADMIN_EMAIL, …)` justo después. |
| 280 | `standalonePage` | Asegura que el prompt **no** aparece en standalone. Sobre la landing pasaría por vacío y no probaría nada. |
| 288 | `unsupportedPage` | Igual: la ausencia solo significa algo donde el prompt debería estar. |
Ejemplo del cambio en la línea 187:
```js
await chromiumPage.goto(loginUrl, { waitUntil: "networkidle" });
```
- [ ] **Step 4: Verificar el audit responsive completo**
Requiere `npm run dev` corriendo.
Run: `npm.cmd run audit:responsive`
Expected: sin hallazgos nuevos. Presta atención a dos filas concretas: `landing` en `iphone-12` (390px) no debe reportar `overflow`, y `login` en cualquier dispositivo táctil no debe reportar `ios-zoom` — si lo hace, alguna utilidad `text-sm` está pisando la regla de 16px en un input.
- [ ] **Step 5: Verificar el audit visual**
Run: `npm.cmd run audit:visual`
Expected: termina sin errores y deja capturas nuevas en `screenshots/`, incluida `landing-*` en los cuatro viewports. Ábrelas: es la única verificación real de que la estética quedó como se diseñó.
- [ ] **Step 6: Confirmar que los tests de API no regresionaron**
Run: `npm.cmd run test:unit`
Then: `npm.cmd run test:e2e`
Then: `npm.cmd run test:admin`
Then: `npm.cmd run test:booking`
Expected: los cuatro en verde. Ninguno depende del ruteo del cliente, así que un fallo aquí significa que se tocó algo del servidor por error.
---
### Task 10: Verificación de build y del aislamiento del chunk
La promesa de que el panel no paga `framer-motion` solo se puede comprobar sobre el build.
**Files:**
- Ninguno. Solo verificación.
**Interfaces:**
- Consumes: todo lo anterior.
- Produces: nada.
- [ ] **Step 1: Build de producción**
Detén `npm run dev` antes (el preflight aborta si `:3000` está ocupado).
Run: `npm.cmd run build`
Expected: `tsc -b` limpio y `vite build` exitoso. En la tabla de salida debe aparecer un chunk `motion-*.js` propio. Si no aparece, el `manualChunks` de la Tarea 1 no se aplicó.
- [ ] **Step 2: Confirmar que la landing es su propio chunk y que el panel no importa motion**
```bash
node -e "const fs=require('fs');const d='dist/assets';const f=fs.readdirSync(d);console.log('chunks:',f.filter(n=>n.endsWith('.js')).join('\n '));"
```
Expected: la lista incluye un `motion-*.js` y un chunk separado para la landing (Vite lo nombra por el módulo dinámico, típicamente `LandingPage-*.js`). Que existan como archivos distintos del entry es lo que prueba el split.
- [ ] **Step 3: Verificar en el navegador que el panel no descarga el chunk de motion**
Run: `npm.cmd start`
Luego, en el navegador con la pestaña de red abierta:
1. Ve a `http://localhost:3000/login` y entra con la tarjeta de la dueña.
2. Filtra las peticiones por `motion`.
Expected: **cero** peticiones a `motion-*.js` durante la sesión del panel. `/login` sí lo carga (usa `revealUp`), lo cual es correcto y esperado: es una página pública. Lo que no debe ocurrir es que `/dashboard` o `/calendar` lo pidan.
- [ ] **Step 4: Correr el test de PWA sobre el build**
Con `npm.cmd start` corriendo en `:3000`:
Run: `npm.cmd run test:pwa`
Expected: `PWA checks passed`. Si falla en `assertInstallAction`, algún `goto` de la Tarea 9 quedó apuntando a `baseUrl` en lugar de `loginUrl`.
- [ ] **Step 5: Repasar la landing a mano en móvil real o emulado**
Abre `http://localhost:3000/` en un iPhone (o en el emulador de dispositivo de Safari/Chrome a 390px) y recorre la landing entera de arriba abajo.
Lista de comprobación:
- El hero no arranca desplazado ni con zoom.
- Nada se puede arrastrar en horizontal.
- Las secciones sticky (3 y 4) no se solapan con la siguiente.
- El nav no tapa el titular del hero.
- Los dos CTA llevan a `/login`.
- [ ] **Step 6: Verificar con movimiento reducido activado**
En macOS: Ajustes → Accesibilidad → Pantalla → Reducir movimiento. En Windows: Configuración → Accesibilidad → Efectos visuales → Efectos de animación (desactivar).
Recarga `/` y recorre la landing.
Expected: todo el contenido visible y legible, sin animaciones. Las secciones 3 y 4 aparecen como pilas estáticas con todos sus visuales presentes, no como paneles vacíos.
- [ ] **Step 7: Cierre — dejar registro**
Añade a `.superpowers/sdd/progress.md` una entrada con: qué se implementó, el resultado de cada comando de verificación, y cualquier follow-up diferido. **No hagas commit.**
---
## Self-Review
**Cobertura del spec:**
| Requisito del spec | Tarea |
|---|---|
| `/` sirve la landing; con sesión redirige | 3 (`App.tsx`), verificado en 2 |
| `/login` enlazable con acceso mágico de un clic | 8, verificado en 2 |
| `npm run typecheck` limpio | pasos finales de 1, 3, 4, 5, 6, 7, 8; build en 10 |
| `audit:responsive` sin hallazgos nuevos, landing incluida | 9 pasos 2 y 4 |
| Cero scroll horizontal | test de la Tarea 2, comprobación 6; audit en 9 |
| `prefers-reduced-motion` legible y estático | Tarea 2 comprobaciones 7; ramas `reduced` en 3-8; manual en 10 paso 6 |
| Chunk de motion no se descarga en el panel | 1 paso 3; verificado en 10 pasos 1-3 |
| Landing con `React.lazy` | 3 paso 1 |
| Ocho piezas de sección, una por archivo | 3, 5, 6, 7 |
| Cinco visuales animados | 3 paso 2 (calendario), 4 (los otros cuatro) |
| Tokens `.landing` en `@layer components` | 1 paso 7 |
| Los siete `goto` de pwa-e2e | 9 paso 3 |
| Formulario manual visible sin interacción | 8 paso 1; test de la Tarea 2 |
| No commits | Global Constraints; Tarea 10 paso 7 |
**Consistencia de nombres verificada:** `homePathFor`, `revealUp`, `revealStagger`, `inView`, `EASE_EXPO`, `baseTransition`, `useReducedMotion`, `CalendarGridVisual`, `RevenueLineVisual`, `TeamLoadVisual`, `NotebookVisual`, `DashboardMockVisual`, `LandingNav`, `HeroSection`, `CostSection`, `ShiftSection`, `ProductSection`, `ProofSection`, `ObjectionsSection`, `ClosingSection`. Todos se definen en una tarea antes de consumirse.
**Atributos de datos** — definidos en la Tarea 2 y producidos donde corresponde: `data-ld-section` (7 secciones: Hero, Cost, Shift, Product, Proof, Objections, Closing), `data-ld-hero-title` (Tarea 3), `data-ld-closing-cta` (Tarea 7), `data-magic-login` + `data-role` (Tarea 8).
**Deuda conocida que este plan deja intacta a propósito:** `npm run lint` sigue roto (ESLint 9 sin config, hueco preexistente), y el modelo de autenticación demo sigue como está. Ninguna de las dos cae en el alcance.