43 KiB
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— nuncaCREEERniCreeer. - 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.jsdebe teneroutput: 'export'yimages.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) andnpm run build(static export) pipelines. -
Produces:
MotionProvider({ children: ReactNode })atcomponents/MotionProvider.tsx, wrapping the app in<LazyMotion features={domAnimation} strict>per spec §8.3 (bundle-size). Consumed byapp/layout.tsxhere, and required context for Tasks 3 and 4, which must use framer-motion'sm.*components (notmotion.*) so thestrictflag 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 })atcomponents/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'smanduseReducedMotion(them.*lightweight primitives, notmotion.*— seeMotionProviderin Task 1, which enforces this viastrict). -
Produces:
MotionSection({ children: ReactNode, className?: string })atcomponents/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' })atcomponents/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()atcomponents/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]">
“Las empresas no dejan de crecer por falta de talento; dejan de
crecer por falta de claridad.”
</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()atcomponents/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()atcomponents/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()atcomponents/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()atcomponents/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">
“El tiempo no se compra, se invierte.”
</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"
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()atcomponents/sections/Closing.tsx, rendering theid="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]">
“Devuélvele claridad al rumbo de tu empresa.”
</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"inapp/page.tsxlocally 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.mdback to the dueña for the HV-1 vs V-a decision — flipping it is a one-linevariantchange inapp/page.tsx. - Once she provides a photo, replace the placeholder
divinAbout.tsxwithnext/image. - Once she provides real contact info, update the three entries in
Closing.tsx'sCONTACT_LINKS. - Consider adding an Open Graph image once final copy/photo are set (spec §8.1).