Files
Creeer-Web/docs/superpowers/plans/2026-07-20-creeer-landing-implementation.md
T

43 KiB
Raw Blame History

creeer Landing Page Implementation Plan

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: Build the one-page creeer landing site (7 sections) as a statically-exported Next.js 14 App Router site, matching the approved design spec exactly.

Architecture: A single app/page.tsx assembles seven independent section components (components/sections/*.tsx), each wrapped in a shared MotionSection scroll-reveal wrapper that respects prefers-reduced-motion. A shared Wordmark component renders the brand mark everywhere it appears. No backend, no client-side state beyond motion — content is static JSX.

Tech Stack: Next.js 14 (App Router) + TypeScript + Tailwind CSS 3 + Framer Motion 11, output: export. Tests: Vitest + React Testing Library + jsdom.

Global Constraints

  • Idioma: español, lang="es" en <html>.
  • Wordmark SIEMPRE minúsculas: creeer — nunca CREEER ni Creeer.
  • Paleta exacta (no aproximar): bg-base #FBFAF7, bg-pure #FFFFFF, bg-dark #0A1F44, text-primary #0A1F44, text-on-dark #F5EBD0, accent-gold #B8901f, accent-gold-bright #D4AF37, text-muted #5a5168, text-muted-dark #9aa6c2. NO amarillo eléctrico tipo Hormozi.
  • Sin backend, sin formulario — contacto solo por enlaces directos (WhatsApp/email/LinkedIn).
  • next.config.js debe tener output: 'export' y images.unoptimized: true (la optimización de imágenes de Next no funciona en export estático).
  • Container max-width: 1152px.
  • Voz: segunda persona ("tú"/"tu negocio"), sin jerga corporativa.
  • Toda animación Framer Motion debe desactivarse con useReducedMotion().
  • Contraste WCAG 2.2 AA: accent-gold (#B8901f) solo para texto grande/decorativo, nunca body text sobre fondo claro.
  • Un único <h1> en toda la página (vive en el Hero).
  • Datos de contacto reales (WhatsApp/email/LinkedIn), foto de la dueña y elección final HV-1 vs V-a están pendientes de la dueña (ver spec §10). Este plan usa placeholders explícitos marcados como tales — no bloquean el build.

Task 1: Next.js scaffold, Tailwind tokens, fonts, root layout

Files:

  • Create: package.json
  • Create: tsconfig.json
  • Create: next.config.js
  • Create: postcss.config.js
  • Create: tailwind.config.ts
  • Create: .eslintrc.json
  • Create: vitest.config.ts
  • Create: vitest.setup.ts
  • Create: test/mocks/next-font-google.ts
  • Create: app/layout.tsx
  • Create: app/layout.test.tsx
  • Create: app/globals.css
  • Create: app/page.tsx (placeholder, replaced in Task 11)
  • Create: components/MotionProvider.tsx

Interfaces:

  • Produces: Tailwind color tokens (bg-base, bg-pure, bg-dark, text-primary, text-on-dark, accent-gold, accent-gold-bright, text-muted, text-muted-dark), font-serif (Georgia), font-sans (Inter via --font-inter), max-w-container (1152px). All later tasks consume these.

  • Produces: working npm test (Vitest) and npm run build (static export) pipelines.

  • Produces: MotionProvider({ children: ReactNode }) at components/MotionProvider.tsx, wrapping the app in <LazyMotion features={domAnimation} strict> per spec §8.3 (bundle-size). Consumed by app/layout.tsx here, and required context for Tasks 3 and 4, which must use framer-motion's m.* components (not motion.*) so the strict flag catches accidental full-bundle imports.

  • Step 1: Write package.json

{
  "name": "creeer",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "framer-motion": "^11.3.19",
    "next": "^14.2.5",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^6.4.8",
    "@testing-library/react": "^16.0.0",
    "@types/node": "^20.14.14",
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.1",
    "autoprefixer": "^10.4.19",
    "eslint": "^8.57.0",
    "eslint-config-next": "^14.2.5",
    "jsdom": "^24.1.1",
    "postcss": "^8.4.40",
    "tailwindcss": "^3.4.7",
    "typescript": "^5.5.4",
    "vitest": "^2.0.5"
  }
}
  • Step 2: Write tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
  • Step 3: Write next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  images: {
    unoptimized: true,
  },
};

module.exports = nextConfig;
  • Step 4: Write postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};
  • Step 5: Write tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        'bg-base': '#FBFAF7',
        'bg-pure': '#FFFFFF',
        'bg-dark': '#0A1F44',
        'text-primary': '#0A1F44',
        'text-on-dark': '#F5EBD0',
        'accent-gold': '#B8901f',
        'accent-gold-bright': '#D4AF37',
        'text-muted': '#5a5168',
        'text-muted-dark': '#9aa6c2',
      },
      fontFamily: {
        serif: ['Georgia', 'serif'],
        sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
      },
      maxWidth: {
        container: '1152px',
      },
    },
  },
  plugins: [],
};

export default config;
  • Step 6: Write .eslintrc.json
{
  "extends": "next/core-web-vitals"
}
  • Step 7: Write test/mocks/next-font-google.ts

Vitest doesn't run through Next's compiler, so next/font/google can't resolve its font manifest. Alias it to this mock so components importing it stay testable.

export function Inter() {
  return {
    className: 'font-inter-mock',
    variable: '--font-inter',
  };
}
  • Step 8: Write vitest.config.ts
import path from 'path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    globals: true,
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, '.'),
      'next/font/google': path.resolve(
        __dirname,
        'test/mocks/next-font-google.ts'
      ),
    },
  },
});
  • Step 9: Write vitest.setup.ts
import '@testing-library/jest-dom/vitest';

class MockIntersectionObserver {
  observe() {}
  unobserve() {}
  disconnect() {}
}

// @ts-expect-error jsdom doesn't implement IntersectionObserver
global.IntersectionObserver = MockIntersectionObserver;

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: (query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: () => {},
    removeListener: () => {},
    addEventListener: () => {},
    removeEventListener: () => {},
    dispatchEvent: () => false,
  }),
});
  • Step 10: Write components/MotionProvider.tsx

Wraps the app in Framer Motion's LazyMotion so every animated component can import the lightweight m.* primitives instead of the full motion.* bundle (spec §8.3: "usar LazyMotion + domAnimation para reducir bundle"). strict makes framer-motion throw if a motion.* component is ever used underneath by mistake.

'use client';

import type { ReactNode } from 'react';
import { LazyMotion, domAnimation } from 'framer-motion';

export function MotionProvider({ children }: { children: ReactNode }) {
  return (
    <LazyMotion features={domAnimation} strict>
      {children}
    </LazyMotion>
  );
}
  • Step 11: Write app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  color-scheme: light;
}

*:focus-visible {
  outline: 3px solid #b8901f;
  outline-offset: 2px;
}
  • Step 12: Write app/layout.tsx
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Inter } from 'next/font/google';
import { MotionProvider } from '@/components/MotionProvider';
import './globals.css';

const inter = Inter({
  subsets: ['latin'],
  weight: ['400', '600', '700', '900'],
  variable: '--font-inter',
  display: 'swap',
});

export const metadata: Metadata = {
  title: 'creeer · tu estratega de crecimiento',
  description:
    'Claridad estratégica para empresarios con tracción que llegaron a un punto de inflexión. Define tu siguiente movimiento.',
};

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="es" className={inter.variable}>
      <body className="bg-bg-base font-sans text-text-primary antialiased">
        <MotionProvider>{children}</MotionProvider>
      </body>
    </html>
  );
}
  • Step 13: Write the failing test app/layout.test.tsx
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import RootLayout, { metadata } from './layout';

describe('RootLayout', () => {
  it('sets the approved SEO title and description', () => {
    expect(metadata.title).toBe('creeer · tu estratega de crecimiento');
    expect(metadata.description).toContain('Claridad estratégica');
  });

  it('declares Spanish as the document language', () => {
    const { container } = render(
      <RootLayout>
        <p>contenido</p>
      </RootLayout>
    );
    expect(container.querySelector('html')).toHaveAttribute('lang', 'es');
  });
});
  • Step 14: Write the placeholder app/page.tsx

Temporary stub — replaced entirely in Task 11 once all sections exist.

export default function Home() {
  return (
    <main>
      <h1>creeer</h1>
    </main>
  );
}
  • Step 15: Install dependencies

Run: npm install Expected: installs without errors, creates node_modules/ and package-lock.json.

  • Step 16: Run the test to verify it passes

Run: npm test -- app/layout.test.tsx Expected: PASS, 2 tests passing.

  • Step 17: Verify the static export build works

Run: npm run build Expected: build succeeds, output includes a line for route /, and out/index.html exists afterward.

  • Step 18: Commit
git add package.json package-lock.json tsconfig.json next.config.js postcss.config.js tailwind.config.ts .eslintrc.json vitest.config.ts vitest.setup.ts test/ app/ components/MotionProvider.tsx
git commit -m "chore: scaffold Next.js app with Tailwind tokens, fonts, and test runner"

Task 2: Wordmark component

Files:

  • Create: components/Wordmark.tsx
  • Create: components/Wordmark.test.tsx

Interfaces:

  • Consumes: Tailwind tokens from Task 1 (text-primary, text-on-dark, accent-gold, font-serif).

  • Produces: Wordmark({ size?: 'base' | 'lg', onDark?: boolean }) at components/Wordmark.tsx. Consumed by Task 4 (Hero).

  • Step 1: Write the failing tests

import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import { Wordmark } from './Wordmark';

describe('Wordmark', () => {
  it('renders the middle "e" in gold with heavier weight than the rest', () => {
    const { container } = render(<Wordmark />);
    const goldE = container.querySelector('.font-black.text-accent-gold');
    expect(goldE).not.toBeNull();
    expect(goldE?.textContent).toBe('e');
  });

  it('renders the trailing period in gold', () => {
    const { container } = render(<Wordmark />);
    const spans = container.querySelectorAll('span.text-accent-gold');
    const period = Array.from(spans).find((el) => el.textContent === '.');
    expect(period).not.toBeUndefined();
  });

  it('never renders the wordmark in uppercase', () => {
    const { container } = render(<Wordmark />);
    expect(container.textContent?.toLowerCase()).toBe(container.textContent);
  });

  it('switches to the on-dark ink color when onDark is true', () => {
    const { container } = render(<Wordmark onDark />);
    expect(container.firstElementChild?.className).toContain(
      'text-text-on-dark'
    );
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- components/Wordmark.test.tsx Expected: FAIL with "Cannot find module './Wordmark'".

  • Step 3: Write components/Wordmark.tsx
export function Wordmark({
  size = 'base',
  onDark = false,
}: {
  size?: 'base' | 'lg';
  onDark?: boolean;
}) {
  const inkColor = onDark ? 'text-text-on-dark' : 'text-text-primary';
  const sizeClass = size === 'lg' ? 'text-4xl md:text-5xl' : 'text-2xl';

  return (
    <span className={`font-serif italic font-bold ${sizeClass} ${inkColor}`}>
      cre
      <span className="font-black text-accent-gold">e</span>
      er
      <span className="text-accent-gold">.</span>
    </span>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm test -- components/Wordmark.test.tsx Expected: PASS, 4 tests passing.

  • Step 5: Commit
git add components/Wordmark.tsx components/Wordmark.test.tsx
git commit -m "feat: add Wordmark component with gold middle-e brand rule"

Task 3: MotionSection scroll-reveal wrapper

Files:

  • Create: components/MotionSection.tsx
  • Create: components/MotionSection.test.tsx

Interfaces:

  • Consumes: framer-motion's m and useReducedMotion (the m.* lightweight primitives, not motion.* — see MotionProvider in Task 1, which enforces this via strict).

  • Produces: MotionSection({ children: ReactNode, className?: string }) at components/MotionSection.tsx, rendering a <section>. Consumed by Tasks 5–10 (all sections except Hero, which has its own staggered entrance).

  • Step 1: Write the failing tests

import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from '@testing-library/react';
import { LazyMotion, domAnimation } from 'framer-motion';
import { MotionSection } from './MotionSection';

function mockReducedMotion(matches: boolean) {
  vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
    matches,
    media: query,
    onchange: null,
    addListener: () => {},
    removeListener: () => {},
    addEventListener: () => {},
    removeEventListener: () => {},
    dispatchEvent: () => false,
  }));
}

function renderWithMotion(ui: React.ReactElement) {
  return render(
    <LazyMotion features={domAnimation}>{ui}</LazyMotion>
  );
}

afterEach(() => {
  cleanup();
  vi.restoreAllMocks();
});

describe('MotionSection', () => {
  it('renders a plain section with no inline animation styles when reduced motion is preferred', () => {
    mockReducedMotion(true);
    const { container } = renderWithMotion(
      <MotionSection>
        <p>contenido</p>
      </MotionSection>
    );
    const section = container.querySelector('section');
    expect(section).not.toBeNull();
    expect(section?.getAttribute('style')).toBeNull();
  });

  it('applies an initial opacity of 0 before entering the viewport when motion is allowed', () => {
    mockReducedMotion(false);
    const { container } = renderWithMotion(
      <MotionSection>
        <p>contenido</p>
      </MotionSection>
    );
    expect(container.querySelector('section')?.getAttribute('style')).toContain(
      'opacity: 0'
    );
  });

  it('always renders its children regardless of motion preference', () => {
    mockReducedMotion(true);
    const { getByText } = renderWithMotion(
      <MotionSection>
        <p>contenido visible</p>
      </MotionSection>
    );
    expect(getByText('contenido visible')).toBeInTheDocument();
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- components/MotionSection.test.tsx Expected: FAIL with "Cannot find module './MotionSection'".

  • Step 3: Write components/MotionSection.tsx
'use client';

import type { ReactNode } from 'react';
import { m, useReducedMotion } from 'framer-motion';

const EASE_OUT_EXPO: [number, number, number, number] = [0.22, 1, 0.36, 1];

export function MotionSection({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  const shouldReduceMotion = useReducedMotion();

  if (shouldReduceMotion) {
    return <section className={className}>{children}</section>;
  }

  return (
    <m.section
      className={className}
      initial={{ opacity: 0, y: 20 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true }}
      transition={{ duration: 0.5, ease: EASE_OUT_EXPO }}
    >
      {children}
    </m.section>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm test -- components/MotionSection.test.tsx Expected: PASS, 3 tests passing.

Note: this component renders m.section without a LazyMotion ancestor in production only through app/layout.tsx's MotionProvider (Task 1) — outside of a real render tree, m components need that provider to have their animation features loaded, which is why the test above wraps every render in its own <LazyMotion features={domAnimation}>.

  • Step 5: Commit
git add components/MotionSection.tsx components/MotionSection.test.tsx
git commit -m "feat: add MotionSection scroll-reveal wrapper with reduced-motion support"

Task 4: Hero section (01)

Files:

  • Create: components/sections/Hero.tsx
  • Create: components/sections/Hero.test.tsx

Interfaces:

  • Consumes: Wordmark (Task 2).
  • Produces: Hero({ variant?: 'hormozi' | 'editorial' }) at components/sections/Hero.tsx, default 'editorial'. Renders the page's single <h1> and the CTA linking to #contacto. Consumed by Task 11.

Note on the default: the spec leaves HV-1 vs V-a as the dueña's decision (§4, §10). variant defaults to 'editorial' (V-a) because it's the closer match to the master tone "seguridad + cercanía" — swap the default or the call site in Task 11 the moment she decides.

  • Step 1: Write the failing tests
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Hero } from './Hero';

describe('Hero', () => {
  it('renders the editorial variant in Georgia italic by default', () => {
    render(<Hero />);
    const headline = screen.getByRole('heading', { level: 1 });
    expect(headline.className).toContain('font-serif');
    expect(headline.className).toContain('italic');
  });

  it('renders the hormozi variant in uppercase sans when requested', () => {
    render(<Hero variant="hormozi" />);
    const headline = screen.getByRole('heading', { level: 1 });
    expect(headline.className).toContain('uppercase');
    expect(headline.className).not.toContain('font-serif');
  });

  it('renders exactly one h1 with the approved headline copy', () => {
    render(<Hero />);
    const headings = screen.getAllByRole('heading', { level: 1 });
    expect(headings).toHaveLength(1);
    expect(headings[0].textContent).toContain('más esfuerzo');
    expect(headings[0].textContent).toContain('más claridad');
  });

  it('renders the CTA linking to the contact section', () => {
    render(<Hero />);
    const cta = screen.getByRole('link', {
      name: /Define tu siguiente movimiento/,
    });
    expect(cta).toHaveAttribute('href', '#contacto');
  });

  it('never shows scarcity language in the hero', () => {
    render(<Hero />);
    const text = screen.getByRole('heading', { level: 1 }).textContent ?? '';
    expect(text).not.toMatch(/cupo|cohorte|generaci[oó]n fundadora/i);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- components/sections/Hero.test.tsx Expected: FAIL with "Cannot find module './Hero'".

  • Step 3: Write components/sections/Hero.tsx
'use client';

import { m, useReducedMotion } from 'framer-motion';
import { Wordmark } from '@/components/Wordmark';

const EASE_OUT_EXPO: [number, number, number, number] = [0.22, 1, 0.36, 1];
const STAGGER_DELAYS = [0, 0.1, 0.2, 0.3];

type HeroVariant = 'hormozi' | 'editorial';

function HeroHeadline({ variant }: { variant: HeroVariant }) {
  if (variant === 'hormozi') {
    return (
      <h1 className="font-sans text-[32px] font-black uppercase leading-tight tracking-[-1.5px] md:text-[48px]">
        Tu negocio no necesita <span className="text-accent-gold">más esfuerzo</span>.
        <br />
        Necesita{' '}
        <span className="underline decoration-accent-gold-bright decoration-[5px] underline-offset-4">
          más claridad
        </span>
        .
      </h1>
    );
  }

  return (
    <h1 className="font-serif text-[32px] font-bold italic leading-tight md:text-[48px]">
      Tu negocio no necesita <span className="text-accent-gold">más esfuerzo</span>.
      <br />
      Necesita{' '}
      <span className="underline decoration-accent-gold underline-offset-4">
        más claridad
      </span>
      .
    </h1>
  );
}

export function Hero({ variant = 'editorial' }: { variant?: HeroVariant }) {
  const shouldReduceMotion = useReducedMotion();

  const item = (delay: number) =>
    shouldReduceMotion
      ? {}
      : {
          initial: { opacity: 0, y: 16 },
          animate: { opacity: 1, y: 0 },
          transition: { duration: 0.5, delay, ease: EASE_OUT_EXPO },
        };

  return (
    <section className="flex min-h-screen flex-col items-center justify-center gap-6 bg-bg-pure px-6 text-center md:px-12">
      <m.div {...item(STAGGER_DELAYS[0])}>
        <Wordmark size="lg" />
      </m.div>
      <m.p
        {...item(STAGGER_DELAYS[1])}
        className="text-[11px] font-semibold uppercase tracking-[3px] text-accent-gold md:text-xs"
      >
        — tu estratega de crecimiento —
      </m.p>
      <m.div {...item(STAGGER_DELAYS[2])}>
        <HeroHeadline variant={variant} />
      </m.div>
      <m.a
        {...item(STAGGER_DELAYS[3])}
        href="#contacto"
        className="mt-4 inline-block bg-text-primary px-8 py-4 text-base font-semibold text-white"
      >
        Define tu siguiente movimiento →
      </m.a>
    </section>
  );
}

Note: Hero's tests only assert className/text/href, none of which depend on the animation feature bundle being loaded, so Hero.test.tsx renders the component directly with no LazyMotion wrapper needed (unlike MotionSection.test.tsx).

  • Step 4: Run tests to verify they pass

Run: npm test -- components/sections/Hero.test.tsx Expected: PASS, 5 tests passing.

  • Step 5: Commit
git add components/sections/Hero.tsx components/sections/Hero.test.tsx
git commit -m "feat: add Hero section with hormozi/editorial variant switch"

Task 5: AnchorQuote section (02)

Files:

  • Create: components/sections/AnchorQuote.tsx
  • Create: components/sections/AnchorQuote.test.tsx

Interfaces:

  • Consumes: MotionSection (Task 3).

  • Produces: AnchorQuote() at components/sections/AnchorQuote.tsx. Consumed by Task 11.

  • Step 1: Write the failing tests

import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { AnchorQuote } from './AnchorQuote';

describe('AnchorQuote', () => {
  it('renders the approved anchor quote verbatim', () => {
    render(<AnchorQuote />);
    expect(
      screen.getByText(/no dejan de crecer por falta de claridad/)
    ).toBeInTheDocument();
  });

  it('does not introduce a second h1 on the page', () => {
    render(<AnchorQuote />);
    expect(screen.queryAllByRole('heading', { level: 1 })).toHaveLength(0);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- components/sections/AnchorQuote.test.tsx Expected: FAIL with "Cannot find module './AnchorQuote'".

  • Step 3: Write components/sections/AnchorQuote.tsx
import { MotionSection } from '@/components/MotionSection';

export function AnchorQuote() {
  return (
    <MotionSection className="bg-bg-base px-6 py-16 md:py-24">
      <blockquote className="mx-auto max-w-container text-center font-serif text-2xl italic leading-snug text-text-primary md:text-[32px]">
        &ldquo;Las empresas no dejan de crecer por falta de talento; dejan de
        crecer por falta de claridad.&rdquo;
      </blockquote>
    </MotionSection>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm test -- components/sections/AnchorQuote.test.tsx Expected: PASS, 2 tests passing.

  • Step 5: Commit
git add components/sections/AnchorQuote.tsx components/sections/AnchorQuote.test.tsx
git commit -m "feat: add AnchorQuote section"

Task 6: ForWhom section (03)

Files:

  • Create: components/sections/ForWhom.tsx
  • Create: components/sections/ForWhom.test.tsx

Interfaces:

  • Consumes: MotionSection (Task 3).

  • Produces: ForWhom() at components/sections/ForWhom.tsx. Consumed by Task 11.

  • Step 1: Write the failing tests

import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ForWhom } from './ForWhom';

describe('ForWhom', () => {
  it('renders exactly the three approved qualifying chips in order', () => {
    render(<ForWhom />);
    const items = screen.getAllByRole('listitem');
    expect(items.map((item) => item.textContent)).toEqual([
      '✓ tienes tracción',
      '✓ más esfuerzo no rinde',
      '✓ necesitas claridad',
    ]);
  });

  it('uses an h2, never a second h1', () => {
    render(<ForWhom />);
    expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
    expect(screen.queryAllByRole('heading', { level: 1 })).toHaveLength(0);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- components/sections/ForWhom.test.tsx Expected: FAIL with "Cannot find module './ForWhom'".

  • Step 3: Write components/sections/ForWhom.tsx
import { MotionSection } from '@/components/MotionSection';

const CHIPS = ['tienes tracción', 'más esfuerzo no rinde', 'necesitas claridad'];

export function ForWhom() {
  return (
    <MotionSection className="bg-bg-base px-6 py-16 md:py-24">
      <div className="mx-auto max-w-container text-center">
        <h2 className="font-sans text-2xl font-bold text-text-primary md:text-4xl">
          ¿Llegaste a tu punto de inflexión?
        </h2>
        <p className="mt-4 text-base text-text-muted md:text-lg">
          Esto es para ti si...
        </p>
        <ul className="mt-8 flex flex-col items-center gap-3 md:flex-row md:justify-center md:gap-4">
          {CHIPS.map((chip) => (
            <li
              key={chip}
              className="rounded border border-accent-gold px-4 py-2 text-sm font-semibold text-text-primary"
            >
              ✓ {chip}
            </li>
          ))}
        </ul>
      </div>
    </MotionSection>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm test -- components/sections/ForWhom.test.tsx Expected: PASS, 2 tests passing.

  • Step 5: Commit
git add components/sections/ForWhom.tsx components/sections/ForWhom.test.tsx
git commit -m "feat: add ForWhom qualifying section"

Task 7: HowIWork section (04, dark)

Files:

  • Create: components/sections/HowIWork.tsx
  • Create: components/sections/HowIWork.test.tsx

Interfaces:

  • Consumes: MotionSection (Task 3).

  • Produces: HowIWork() at components/sections/HowIWork.tsx. Consumed by Task 11.

  • Step 1: Write the failing tests

import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { HowIWork } from './HowIWork';

describe('HowIWork', () => {
  it('renders the three mechanism steps in order, numbered, with no icons', () => {
    render(<HowIWork />);
    const items = screen.getAllByRole('listitem');
    expect(items.map((item) => item.textContent)).toEqual([
      '01Inmersión',
      '02Desafiar supuestos',
      '03Estrategia ejecutable',
    ]);
    expect(screen.queryAllByRole('img')).toHaveLength(0);
  });

  it('uses the dark section background', () => {
    const { container } = render(<HowIWork />);
    expect(container.querySelector('section')?.className).toContain(
      'bg-bg-dark'
    );
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- components/sections/HowIWork.test.tsx Expected: FAIL with "Cannot find module './HowIWork'".

  • Step 3: Write components/sections/HowIWork.tsx
import { MotionSection } from '@/components/MotionSection';

const STEPS = [
  { number: '01', title: 'Inmersión' },
  { number: '02', title: 'Desafiar supuestos' },
  { number: '03', title: 'Estrategia ejecutable' },
];

export function HowIWork() {
  return (
    <MotionSection className="bg-bg-dark px-6 py-16 md:py-24">
      <div className="mx-auto max-w-container">
        <h2 className="text-center font-sans text-2xl font-bold text-text-on-dark md:text-4xl">
          Cómo trabajo
        </h2>
        <ol className="mt-12 grid gap-10 md:grid-cols-3">
          {STEPS.map((step) => (
            <li key={step.number} className="text-center">
              <span className="font-serif text-5xl font-bold italic text-accent-gold-bright md:text-6xl">
                {step.number}
              </span>
              <p className="mt-4 text-lg font-semibold text-text-on-dark">
                {step.title}
              </p>
            </li>
          ))}
        </ol>
      </div>
    </MotionSection>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm test -- components/sections/HowIWork.test.tsx Expected: PASS, 2 tests passing.

  • Step 5: Commit
git add components/sections/HowIWork.tsx components/sections/HowIWork.test.tsx
git commit -m "feat: add HowIWork dark mechanism section"

Task 8: Disciplines section (05)

Files:

  • Create: components/sections/Disciplines.tsx
  • Create: components/sections/Disciplines.test.tsx

Interfaces:

  • Consumes: MotionSection (Task 3).

  • Produces: Disciplines() at components/sections/Disciplines.tsx. Consumed by Task 11.

  • Step 1: Write the failing tests

import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Disciplines } from './Disciplines';

describe('Disciplines', () => {
  it('renders the three disciplines as tools, not as the core promise', () => {
    render(<Disciplines />);
    expect(screen.getByText('Comunicación')).toBeInTheDocument();
    expect(screen.getByText('Digital')).toBeInTheDocument();
    expect(screen.getByText('Experiencias')).toBeInTheDocument();
  });

  it('gives each card a gold top border', () => {
    const { container } = render(<Disciplines />);
    expect(container.querySelectorAll('.border-t-4')).toHaveLength(3);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- components/sections/Disciplines.test.tsx Expected: FAIL with "Cannot find module './Disciplines'".

  • Step 3: Write components/sections/Disciplines.tsx
import { MotionSection } from '@/components/MotionSection';

const DISCIPLINES = ['Comunicación', 'Digital', 'Experiencias'];

export function Disciplines() {
  return (
    <MotionSection className="bg-bg-base px-6 py-16 md:py-24">
      <div className="mx-auto max-w-container">
        <h2 className="text-center font-sans text-2xl font-bold text-text-primary md:text-4xl">
          Las herramientas
        </h2>
        <div className="mt-12 grid gap-6 md:grid-cols-3">
          {DISCIPLINES.map((discipline) => (
            <div
              key={discipline}
              className="border-t-4 border-accent-gold px-6 py-8 text-center"
            >
              <p className="font-sans text-lg font-semibold text-text-primary">
                {discipline}
              </p>
            </div>
          ))}
        </div>
      </div>
    </MotionSection>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm test -- components/sections/Disciplines.test.tsx Expected: PASS, 2 tests passing.

  • Step 5: Commit
git add components/sections/Disciplines.tsx components/sections/Disciplines.test.tsx
git commit -m "feat: add Disciplines section"

Task 9: About section (06)

Files:

  • Create: components/sections/About.tsx
  • Create: components/sections/About.test.tsx

Interfaces:

  • Consumes: MotionSection (Task 3).
  • Produces: About() at components/sections/About.tsx. Consumed by Task 11.

Note: no professional photo exists yet (spec §5, §10 — pending). Renders a navy/gold gradient placeholder labeled for screen readers; swap the div for a Next <Image src=... alt=... /> the moment a photo is provided (images.unoptimized: true from Task 1 already makes this drop-in safe under static export).

  • Step 1: Write the failing tests
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { About } from './About';

describe('About', () => {
  it('labels the portrait placeholder for screen readers until a real photo is provided', () => {
    render(<About />);
    expect(
      screen.getByRole('img', { name: /Retrato de la estratega/ })
    ).toBeInTheDocument();
  });

  it('renders the first-person bio and the institutional quote', () => {
    render(<About />);
    expect(screen.getByText(/Hoy ayudo a empresarios/)).toBeInTheDocument();
    expect(
      screen.getByText(/El tiempo no se compra, se invierte/)
    ).toBeInTheDocument();
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- components/sections/About.test.tsx Expected: FAIL with "Cannot find module './About'".

  • Step 3: Write components/sections/About.tsx
import { MotionSection } from '@/components/MotionSection';

export function About() {
  return (
    <MotionSection className="bg-bg-base px-6 py-16 md:py-24">
      <div className="mx-auto flex max-w-container flex-col items-center gap-8 md:flex-row md:gap-12">
        <div
          role="img"
          aria-label="Retrato de la estratega de creeer (foto pendiente)"
          className="h-48 w-48 shrink-0 rounded bg-gradient-to-br from-bg-dark to-accent-gold"
        />
        <div className="text-center md:text-left">
          <h2 className="font-sans text-2xl font-bold text-text-primary md:text-4xl">
            La mente detrás de creeer
          </h2>
          <p className="mt-4 text-base text-text-muted md:text-lg">
            +30 años construyendo marcas. Hoy ayudo a empresarios con
            tracción a encontrar la claridad estratégica que los lleva a su
            siguiente movimiento.
          </p>
          <p className="mt-4 font-serif text-lg italic text-text-primary">
            &ldquo;El tiempo no se compra, se invierte.&rdquo;
          </p>
        </div>
      </div>
    </MotionSection>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm test -- components/sections/About.test.tsx Expected: PASS, 2 tests passing.

  • Step 5: Commit
git add components/sections/About.tsx components/sections/About.test.tsx
git commit -m "feat: add About section with portrait placeholder"

Files:

  • Create: components/sections/Closing.tsx
  • Create: components/sections/Closing.test.tsx

Interfaces:

  • Consumes: MotionSection (Task 3).
  • Produces: Closing() at components/sections/Closing.tsx, rendering the id="contacto" anchor target that Hero's CTA (Task 4) scrolls to. Consumed by Task 11.

Note: WhatsApp number, email, and LinkedIn URL are placeholders pending the dueña's real contact details (spec §10). Replace the three values in CONTACT_LINKS the moment she provides them — no other code changes needed.

  • Step 1: Write the failing tests
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Closing } from './Closing';

describe('Closing', () => {
  it('exposes the three contact channels with accessible labels', () => {
    render(<Closing />);
    expect(
      screen.getByRole('link', { name: 'Escribir por WhatsApp' })
    ).toHaveAttribute('href', 'https://wa.me/521234567890');
    expect(
      screen.getByRole('link', { name: 'Escribir por correo electrónico' })
    ).toHaveAttribute('href', 'mailto:hola@creeer.com');
    expect(
      screen.getByRole('link', { name: 'Ver perfil de LinkedIn' })
    ).toHaveAttribute('href', 'https://www.linkedin.com/in/creeer');
  });

  it('points the closing CTA at the primary contact channel', () => {
    render(<Closing />);
    const cta = screen.getByRole('link', {
      name: 'Define tu siguiente movimiento →',
    });
    expect(cta).toHaveAttribute('href', 'https://wa.me/521234567890');
  });

  it('provides the #contacto anchor target for the hero CTA to scroll to', () => {
    const { container } = render(<Closing />);
    expect(container.querySelector('#contacto')).not.toBeNull();
  });

  it('uses the dark section background', () => {
    const { container } = render(<Closing />);
    expect(container.querySelector('section')?.className).toContain(
      'bg-bg-dark'
    );
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- components/sections/Closing.test.tsx Expected: FAIL with "Cannot find module './Closing'".

  • Step 3: Write components/sections/Closing.tsx
import { MotionSection } from '@/components/MotionSection';

const CONTACT_LINKS = [
  {
    label: 'WhatsApp',
    href: 'https://wa.me/521234567890',
    ariaLabel: 'Escribir por WhatsApp',
  },
  {
    label: 'Email',
    href: 'mailto:hola@creeer.com',
    ariaLabel: 'Escribir por correo electrónico',
  },
  {
    label: 'LinkedIn',
    href: 'https://www.linkedin.com/in/creeer',
    ariaLabel: 'Ver perfil de LinkedIn',
  },
];

export function Closing() {
  return (
    <MotionSection className="bg-bg-dark px-6 py-16 md:py-24">
      <div id="contacto" className="mx-auto max-w-container text-center">
        <p className="font-serif text-2xl italic text-text-on-dark md:text-[32px]">
          &ldquo;Devuélvele claridad al rumbo de tu empresa.&rdquo;
        </p>
        <a
          href={CONTACT_LINKS[0].href}
          className="mt-8 inline-block bg-accent-gold-bright px-8 py-4 text-base font-semibold text-bg-dark"
        >
          Define tu siguiente movimiento →
        </a>
        <ul className="mt-10 flex flex-col items-center gap-4 md:flex-row md:justify-center md:gap-8">
          {CONTACT_LINKS.map((link) => (
            <li key={link.label}>
              <a
                href={link.href}
                aria-label={link.ariaLabel}
                className="text-sm font-semibold uppercase tracking-wide text-text-on-dark underline decoration-accent-gold-bright underline-offset-4"
              >
                {link.label}
              </a>
            </li>
          ))}
        </ul>
      </div>
    </MotionSection>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm test -- components/sections/Closing.test.tsx Expected: PASS, 4 tests passing.

  • Step 5: Commit
git add components/sections/Closing.tsx components/sections/Closing.test.tsx
git commit -m "feat: add Closing section with contact links"

Task 11: Assemble the page, structured data, and verify the build

Files:

  • Modify: app/page.tsx (replace the Task 1 placeholder)
  • Create: app/page.test.tsx

Interfaces:

  • Consumes: Hero (Task 4), AnchorQuote (Task 5), ForWhom (Task 6), HowIWork (Task 7), Disciplines (Task 8), About (Task 9), Closing (Task 10).

  • Step 1: Write the failing tests

import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import Home from './page';

describe('Home page', () => {
  it('renders exactly one h1 across the whole page', () => {
    render(<Home />);
    expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
  });

  it('renders the seven sections in spec order', () => {
    const { container } = render(<Home />);
    expect(container.querySelectorAll('section')).toHaveLength(7);
  });

  it('repeats the primary CTA copy at the top and the bottom of the page', () => {
    render(<Home />);
    expect(
      screen.getAllByText('Define tu siguiente movimiento →')
    ).toHaveLength(2);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- app/page.test.tsx Expected: FAIL — the placeholder page only renders one <h1> and zero <section> elements, so the section-count and CTA-repetition assertions fail.

  • Step 3: Replace app/page.tsx
import { Hero } from '@/components/sections/Hero';
import { AnchorQuote } from '@/components/sections/AnchorQuote';
import { ForWhom } from '@/components/sections/ForWhom';
import { HowIWork } from '@/components/sections/HowIWork';
import { Disciplines } from '@/components/sections/Disciplines';
import { About } from '@/components/sections/About';
import { Closing } from '@/components/sections/Closing';

const STRUCTURED_DATA = {
  '@context': 'https://schema.org',
  '@type': 'ProfessionalService',
  name: 'creeer',
  description:
    'Claridad estratégica para empresarios con tracción que llegaron a un punto de inflexión.',
  url: 'https://creeer.com',
};

export default function Home() {
  return (
    <main>
      <Hero variant="editorial" />
      <AnchorQuote />
      <ForWhom />
      <HowIWork />
      <Disciplines />
      <About />
      <Closing />
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(STRUCTURED_DATA) }}
      />
    </main>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm test Expected: PASS, all test files green (Task 1 through Task 11).

  • Step 5: Verify lint passes

Run: npm run lint Expected: "No ESLint warnings or errors".

  • Step 6: Verify the static export build

Run: npm run build Expected: build succeeds; out/index.html exists and contains the wordmark text creeer, the seven <section tags, and the JSON-LD <script type="application/ld+json"> block.

  • Step 7: Manual visual check

Run: npm run dev, open http://localhost:3000, and confirm against the spec:

  • Scroll through all 7 sections in order; sections 04 and 07 are navy with gold accents, the rest are light.

  • The hero entrance animates in (wordmark → eyebrow → headline → CTA).

  • Toggle variant="hormozi" in app/page.tsx locally to eyeball the other hero option for the dueña's later review, then revert to "editorial" before committing.

  • In OS/browser settings enable "reduce motion" and reload — all animations should disappear with content still visible. Stop the dev server (Ctrl+C) when done.

  • Step 8: Commit

git add app/page.tsx app/page.test.tsx
git commit -m "feat: assemble the seven-section landing page with structured data"

After this plan

  • Hand hero-finalists.md back to the dueña for the HV-1 vs V-a decision — flipping it is a one-line variant change in app/page.tsx.
  • Once she provides a photo, replace the placeholder div in About.tsx with next/image.
  • Once she provides real contact info, update the three entries in Closing.tsx's CONTACT_LINKS.
  • Consider adding an Open Graph image once final copy/photo are set (spec §8.1).