Files
AgendaPro/docs/superpowers/plans/2026-07-28-landing-funnel-bi.md
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

99 KiB

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

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.

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:

        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":

    "test:landing": "node landing-e2e.mjs",
  • Step 5: Crear src/lib/motion.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
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:

  /* ---- 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:

/* 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

// 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:

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

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
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
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.

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

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
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
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
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

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
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
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

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
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 />:

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

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
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
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
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

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:

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):

  await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
  • Step 2: responsive-audit.mjs — lo mismo

En PAGES (línea 53):

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):

  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):

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:

    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
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.