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

1436 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`**
```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`**
```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`**
```js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},
};
module.exports = nextConfig;
```
- [ ] **Step 4: Write `postcss.config.js`**
```js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
- [ ] **Step 5: Write `tailwind.config.ts`**
```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`**
```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.
```ts
export function Inter() {
return {
className: 'font-inter-mock',
variable: '--font-inter',
};
}
```
- [ ] **Step 8: Write `vitest.config.ts`**
```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`**
```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.
```tsx
'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`**
```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`**
```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`**
```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.
```tsx
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**
```bash
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**
```tsx
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`**
```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**
```bash
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**
```tsx
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`**
```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**
```bash
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**
```tsx
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`**
```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**
```bash
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**
```tsx
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`**
```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**
```bash
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**
```tsx
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`**
```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**
```bash
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**
```tsx
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`**
```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**
```bash
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**
```tsx
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`**
```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**
```bash
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**
```tsx
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`**
```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**
```bash
git add components/sections/About.tsx components/sections/About.test.tsx
git commit -m "feat: add About section with portrait placeholder"
```
---
### Task 10: Closing section (07, dark) + contact links
**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**
```tsx
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`**
```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**
```bash
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**
```tsx
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`**
```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**
```bash
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).