diff --git a/app/globals.css b/app/globals.css index 5aeba58..18c262f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,7 +6,7 @@ html { scroll-behavior: smooth; } body { background-color: #FFFFFF; - color: #0A1D5D; + color: #0A1D3D; font-family: var(--font-open-sans), system-ui, sans-serif; -webkit-font-smoothing: antialiased; } diff --git a/app/icon.svg b/app/icon.svg index 30115cc..ba62a51 100644 --- a/app/icon.svg +++ b/app/icon.svg @@ -1,5 +1,5 @@ - + diff --git a/components/primitives/CTA.tsx b/components/primitives/CTA.tsx index 8042080..a09ecda 100644 --- a/components/primitives/CTA.tsx +++ b/components/primitives/CTA.tsx @@ -1,7 +1,10 @@ -type Variant = 'light' | 'dark'; +type Variant = 'light' | 'dark' | 'ghost'; +// Every variant carries border-2 so filled and outlined CTAs line up when +// they sit side by side. const styles: Record = { - light: 'bg-azul-profundo text-white hover:bg-azul-profundo/90', - dark: 'bg-dorado text-white hover:bg-dorado/90', + light: 'border-2 border-transparent bg-azul-profundo text-white hover:bg-azul-profundo/90', + dark: 'border-2 border-transparent bg-dorado text-white hover:bg-dorado/90', + ghost: 'border-2 border-azul-profundo text-azul-profundo hover:bg-azul-profundo hover:text-white', }; export function CTA({ href, label, variant = 'light', className = '' }: { href: string; label: string; variant?: Variant; className?: string }) { diff --git a/components/primitives/SimboloTresLineas.tsx b/components/primitives/SimboloTresLineas.tsx new file mode 100644 index 0000000..8059497 --- /dev/null +++ b/components/primitives/SimboloTresLineas.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useRef, useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { usePrefersReducedMotion } from '@/lib/motion'; + +// The manual's own description of the ≡ symbol, made literal: "las líneas van +// de muchas/desordenadas (complejidad) a pocas/alineadas (claridad)". Five +// uneven bars resolve, once in view, into the three clean aligned bars of the +// official icon (see app/icon.svg) — decorative, one-shot, never loops. +const FINAL = [ + { x: 13, y: 13, width: 38, height: 8 }, + { x: 13, y: 28, width: 38, height: 8 }, + { x: 13, y: 43, width: 38, height: 8 }, +]; + +const BARS = [ + { from: { x: 6, y: 9, width: 22, height: 6, opacity: 0.9 }, to: FINAL[0] }, + { from: { x: 36, y: 15, width: 16, height: 5, opacity: 0.45 }, to: { x: 44, y: 17, width: 8, height: 5, opacity: 0 } }, + { from: { x: 16, y: 25, width: 30, height: 7, opacity: 0.9 }, to: FINAL[1] }, + { from: { x: 8, y: 37, width: 14, height: 5, opacity: 0.45 }, to: { x: 12, y: 39, width: 8, height: 5, opacity: 0 } }, + { from: { x: 20, y: 45, width: 26, height: 6, opacity: 0.9 }, to: FINAL[2] }, +]; + +export function SimboloTresLineas({ className = '' }: { className?: string }) { + const reduced = usePrefersReducedMotion(); + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || reduced) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + observer.unobserve(el); + } + }, + { threshold: 0.4 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [reduced]); + + return ( + + ); +} diff --git a/components/primitives/Wordmark.tsx b/components/primitives/Wordmark.tsx index bb995d3..2ba7e3b 100644 --- a/components/primitives/Wordmark.tsx +++ b/components/primitives/Wordmark.tsx @@ -1,22 +1,59 @@ -import { wordmark } from '@/lib/content'; +import { brandPillars } from '@/lib/content'; type Size = 'sm' | 'md' | 'lg' | 'xl'; +// Widths of the mark itself. sm/md match the optical size of the type-set +// wordmark they replaced; lg/xl are sized so the tagline stays legible at the +// manual's lockup proportions. const sizeClass: Record = { - sm: 'text-xl', - md: 'text-2xl', - lg: 'text-3xl', - xl: 'text-5xl', + sm: 'w-[72px]', + md: 'w-[87px]', + lg: 'w-[200px] md:w-[260px]', + xl: 'w-[260px] md:w-[350px]', }; -export function Wordmark({ size = 'lg', onDark = false }: { size?: Size; onDark?: boolean }) { - const { before, after } = wordmark.parts; +// Logotype traced from the Manual de Identidad Visual v1.0 (Aptos Regular outlines, +// so the file carries no font dependency). Geometry measured off the manual: +// bar thickness = x-height/7, gap = 2x thickness, bar length = 0.845 x-height, +// and the ≡ occupies exactly one 'e' advance, spanning x-height to baseline. +const LETTERS = + 'M68.5 153L68.5 153Q48.4 153 32.9 144.6Q17.5 136.1 8.7 119.2Q0 102.3 0 76.8L0 76.8Q0 51.3 8.6 34.2Q17.2 17.2 32.6 8.6Q48 0 68.5 0L68.5 0Q85.6 0 99.3 5.6Q113.1 11.2 121.3 22.5Q129.5 33.8 129.9 51.1L129.9 51.1L104.9 51.1Q104 36.9 94.6 28.7Q85.1 20.5 68.9 20.5L68.9 20.5Q47.4 20.5 36.7 35.3Q26.1 50.1 26.1 76.8L26.1 76.8Q26.1 94.6 30.8 106.9Q35.5 119.3 45.1 125.9Q54.6 132.5 68.9 132.5L68.9 132.5Q85.1 132.5 95.3 124.1Q105.5 115.7 105.9 99.4L105.9 99.4L131 99.4Q130.5 117.3 122.3 129.2Q114 141.2 99.9 147.1Q85.9 153 68.5 153M228.7 150.4L203.4 150.4L203.4 2.6L227.2 2.6L228.1 27.8Q234.3 16.2 245.4 8.5Q256.5 0.8 274.2 0.8L274.2 0.8L277.3 0.8L277.3 24L273.8 24Q262.7 24 254.4 26.9Q246 29.7 239.8 34.8Q233.6 39.9 228.7 46.6L228.7 46.6L228.7 150.4M400.7 153.1L400.7 153.1Q376.5 153.1 361.1 143.1Q345.7 133 338.3 115.6Q330.9 98.2 330.9 76.5L330.9 76.5Q330.9 53.9 338.1 36.7Q345.3 19.4 360.1 9.7Q374.9 0 397.6 0L397.6 0Q427.8 0 445 18.2Q462.1 36.4 462.1 70.6L462.1 70.6Q462.1 72.9 462 76Q461.8 79.1 461.5 80.4L461.5 80.4L356.2 80.4Q356.3 94.9 360.8 106.9Q365.3 119 375 126.3Q384.7 133.6 400.7 133.6L400.7 133.6Q417.7 133.6 427.2 125.6Q436.8 117.6 437.2 102.6L437.2 102.6L462.3 102.6Q461.8 118.2 454.2 129.5Q446.7 140.8 433 147Q419.3 153.1 400.7 153.1M356.3 61.9L356.3 61.9L437.1 61.9Q436.6 41.3 426.3 30.4Q416 19.4 397.6 19.4L397.6 19.4Q379.1 19.4 368.6 30.3Q358 41.1 356.3 61.9M791.9 153.1L791.9 153.1Q767.8 153.1 752.4 143.1Q737 133 729.5 115.6Q722.1 98.2 722.1 76.5L722.1 76.5Q722.1 53.9 729.3 36.7Q736.5 19.4 751.3 9.7Q766.1 0 788.9 0L788.9 0Q819.1 0 836.2 18.2Q853.4 36.4 853.4 70.6L853.4 70.6Q853.4 72.9 853.2 76Q853.1 79.1 852.8 80.4L852.8 80.4L747.5 80.4Q747.6 94.9 752.1 106.9Q756.6 119 766.3 126.3Q776 133.6 791.9 133.6L791.9 133.6Q808.9 133.6 818.5 125.6Q828 117.6 828.5 102.6L828.5 102.6L853.5 102.6Q853.1 118.2 845.5 129.5Q837.9 140.8 824.3 147Q810.6 153.1 791.9 153.1M747.6 61.9L747.6 61.9L828.4 61.9Q827.9 41.3 817.6 30.4Q807.3 19.4 788.9 19.4L788.9 19.4Q770.4 19.4 759.8 30.3Q749.3 41.1 747.6 61.9M951.4 150.4L926.1 150.4L926.1 2.6L949.9 2.6L950.8 27.8Q957 16.2 968.1 8.5Q979.2 0.8 997 0.8L997 0.8L1000 0.8L1000 24L996.5 24Q985.4 24 977.1 26.9Q968.7 29.7 962.5 34.8Q956.3 39.9 951.4 46.6L951.4 46.6L951.4 150.4'; +const BARS = [2.6, 65.9, 129.3]; + +// Lockup geometry, in the same 1000-unit space as the letters (see manual): +// the tagline sits 0.7557 x-heights below the baseline, its cap height is +// 0.2047 x-heights, and it is justified to the exact width of the mark. +const MARK_HEIGHT = 153.14; +const TAG_BASELINE = 262.13; +const TAG_SIZE = 42.38; // cap height 30.26 / Open Sans capHeight-per-em 0.714 + +export function Wordmark({ size = 'lg', onDark = false, showTagline = false }: { size?: Size; onDark?: boolean; showTagline?: boolean }) { const baseColor = onDark ? 'text-white' : 'text-azul-profundo'; - const goldColor = 'text-dorado'; + const pillars = [brandPillars.pillar1, brandPillars.pillar2, brandPillars.pillar3].map((p) => p.toUpperCase()); + const label = showTagline ? `creeer — ${pillars.join(' · ')}` : 'creeer'; return ( - - {before} - ≡ - {after} - + + + + {BARS.map((y) => ( + + ))} + + {showTagline && ( + // textLength pins the tagline to the width of the mark, so the lockup + // holds its proportions whatever the rendered font does. + + {pillars[0]} + · + {pillars[1]} + · + {pillars[2]} + + )} + ); } diff --git a/components/primitives/__tests__/CTA.test.tsx b/components/primitives/__tests__/CTA.test.tsx index b963b6d..cd054eb 100644 --- a/components/primitives/__tests__/CTA.test.tsx +++ b/components/primitives/__tests__/CTA.test.tsx @@ -11,4 +11,22 @@ describe('', () => { render(); expect(screen.getByRole('link').getAttribute('href')).toBe('#contacto'); }); + it('renders the ghost variant outlined, so it reads as secondary', () => { + render(); + const classes = screen.getByRole('link').className.split(/\s+/); + expect(classes).toContain('border-azul-profundo'); + expect(classes).not.toContain('bg-azul-profundo'); // sólo se rellena en hover + expect(classes).toContain('hover:bg-azul-profundo'); + }); + it('keeps every variant the same height', () => { + const { container } = render( + <> + + + , + ); + const [light, ghost] = Array.from(container.querySelectorAll('a')); + expect(light.className).toContain('border-2'); + expect(ghost.className).toContain('border-2'); + }); }); diff --git a/components/primitives/__tests__/SimboloTresLineas.test.tsx b/components/primitives/__tests__/SimboloTresLineas.test.tsx new file mode 100644 index 0000000..035f972 --- /dev/null +++ b/components/primitives/__tests__/SimboloTresLineas.test.tsx @@ -0,0 +1,22 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { SimboloTresLineas } from '../SimboloTresLineas'; + +describe('', () => { + it('is decorative: hidden from assistive tech', () => { + const { container } = render(); + expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('renders five bars resolving toward the three-line symbol', () => { + const { container } = render(); + expect(container.querySelectorAll('rect')).toHaveLength(5); + }); + + it('takes its colour from currentColor, like the other brand graphics', () => { + const { container } = render(); + container.querySelectorAll('rect').forEach((rect) => { + expect(rect).toHaveAttribute('fill', 'currentColor'); + }); + }); +}); diff --git a/components/primitives/__tests__/Wordmark.test.tsx b/components/primitives/__tests__/Wordmark.test.tsx index 6dcba6c..04152b4 100644 --- a/components/primitives/__tests__/Wordmark.test.tsx +++ b/components/primitives/__tests__/Wordmark.test.tsx @@ -3,20 +3,33 @@ import { describe, it, expect } from 'vitest'; import { Wordmark } from '../Wordmark'; describe('', () => { - it('renders the wordmark in lowercase', () => { + it('exposes the brand name in lowercase as its accessible name', () => { render(); - const text = screen.getByText(/cre/).closest('span')?.textContent; - expect(text?.toLowerCase()).toBe('cre≡er'); + expect(screen.getByRole('img', { name: 'creeer' })).toBeInTheDocument(); }); - it('renders the accent "≡" symbol separately for dorado styling', () => { + it('renders the "≡" as three separate bars for dorado styling', () => { const { container } = render(); const accent = container.querySelector('[data-accent="true"]'); expect(accent).not.toBeNull(); - expect(accent?.textContent).toBe('≡'); + expect(accent?.classList.contains('fill-dorado')).toBe(true); + expect(accent?.querySelectorAll('rect')).toHaveLength(3); }); it('never renders uppercase CREEER', () => { - const { container } = render(); + const { container } = render(); expect(container.textContent).not.toMatch(/CREEER/); expect(container.textContent).not.toMatch(/Creeer/); + expect(screen.getByRole('img').getAttribute('aria-label')).toMatch(/^creeer\b/); + }); + it('renders the tagline pillars with dorado separators, justified to the mark', () => { + const { container } = render(); + const tagline = container.querySelector('text'); + expect(tagline?.textContent).toBe('CLARIDAD · DIRECCIÓN · CRECIMIENTO'); + expect(tagline?.getAttribute('textLength')).toBe('1000'); + expect(tagline?.querySelectorAll('.fill-dorado')).toHaveLength(2); + }); + it('omits the tagline unless asked, and shortens the viewBox accordingly', () => { + const { container } = render(); + expect(container.querySelector('text')).toBeNull(); + expect(container.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 1000 153.14'); }); }); diff --git a/package.json b/package.json index d7008ee..7aad0a2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "test:watch": "vitest" }, "dependencies": { + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", "framer-motion": "^11.18.2", "next": "14.2.35", "react": "^18", @@ -19,6 +21,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^15.0.7", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d10fd06..31feabd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + embla-carousel-autoplay: + specifier: ^8.6.0 + version: 8.6.0(embla-carousel@8.6.0) + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@18.3.1) framer-motion: specifier: ^11.18.2 version: 11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -27,6 +33,9 @@ importers: '@testing-library/react': specifier: ^15.0.7 version: 15.0.7(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^20 version: 20.19.43 @@ -658,6 +667,12 @@ packages: '@types/react': optional: true + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1240,6 +1255,24 @@ packages: electron-to-chromium@1.5.393: resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} + embla-carousel-autoplay@8.6.0: + resolution: {integrity: sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3223,6 +3256,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -3820,6 +3857,22 @@ snapshots: electron-to-chromium@1.5.393: {} + embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-react@8.6.0(react@18.3.1): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 18.3.1 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} diff --git a/public/logo-creeer-negativa.svg b/public/logo-creeer-negativa.svg new file mode 100644 index 0000000..b7ee1d6 --- /dev/null +++ b/public/logo-creeer-negativa.svg @@ -0,0 +1,14 @@ + + cre≡er — Claridad · Dirección · Crecimiento + + + + + + + + + + diff --git a/public/logo-creeer-principal.svg b/public/logo-creeer-principal.svg new file mode 100644 index 0000000..d7a16b0 --- /dev/null +++ b/public/logo-creeer-principal.svg @@ -0,0 +1,14 @@ + + cre≡er — Claridad · Dirección · Crecimiento + + + + + + + + + + diff --git a/public/logo-creeer-secundaria.svg b/public/logo-creeer-secundaria.svg new file mode 100644 index 0000000..deca5f4 --- /dev/null +++ b/public/logo-creeer-secundaria.svg @@ -0,0 +1,7 @@ + + cre≡er — Claridad · Dirección · Crecimiento + + + + + diff --git a/tailwind.config.ts b/tailwind.config.ts index 36bb3d0..7e10cec 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -7,7 +7,7 @@ const config: Config = { colors: { // — Manual de Identidad Visual v1.0 (mayo 2026) — // Colores principales - 'azul-profundo': '#0A1D5D', // Pantone 2965 C — texto, fondos oscuros, CTAs + 'azul-profundo': '#0A1D3D', // Pantone 2965 C — texto, fondos oscuros, CTAs 'dorado': { DEFAULT: '#C4942E', // Pantone 872 C — acentos, títulos, símbolo ≡ }, @@ -16,8 +16,8 @@ const config: Config = { 'gris-claro': '#F2F2F2', // fondos neutros, separadores 'gris-medio': '#AAAAAA', // texto secundario, placeholders // Aliases semánticos (backward compat + legibilidad en componentes) - ink: '#0A1D5D', // texto principal - 'bg-dark': '#0A1D5D', // secciones oscuras + ink: '#0A1D3D', // texto principal + 'bg-dark': '#0A1D3D', // secciones oscuras }, fontFamily: { // Open Sans (OFL) vía next/font/google — lo más cercano a Aptos en open-source diff --git a/vitest.setup.ts b/vitest.setup.ts index 6ddd8ec..3b66592 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -18,6 +18,18 @@ if (typeof window !== 'undefined' && !('IntersectionObserver' in window)) { global.IntersectionObserver = IntersectionObserverStub; } +// jsdom lacks ResizeObserver; Embla Carousel (Carrusel primitive) needs it. +if (typeof window !== 'undefined' && !('ResizeObserver' in window)) { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + // @ts-expect-error – installing stub on window + window.ResizeObserver = ResizeObserverStub; + global.ResizeObserver = ResizeObserverStub; +} + // jsdom lacks matchMedia; usePrefersReducedMotion() calls it in useEffect. // Note: vitest's jsdom environment pre-defines `matchMedia` as an own property // set to `undefined`, so `'matchMedia' in window` is true even though it isn't