feat(identidad): logos SVG oficiales, wordmark trazado y tokens del manual
El wordmark deja de depender del glifo tipográfico ≡ y pasa a un trazado SVG propio (SimboloTresLineas), que es lo que permite cumplir la instrucción de la dueña: que se lea como E, no como tres líneas sueltas. Incluye las tres versiones del logotipo (principal, negativa, secundaria) y alinea los tokens de color con el Manual de Identidad Visual v1.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
edf921eae7
commit
a9758a92f9
15 files changed
+287
-27
No files matched your search
@@ -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<Variant, string> = {
|
||||
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 }) {
|
||||
|
||||
@@ -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<SVGSVGElement>(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 (
|
||||
<svg ref={ref} viewBox="0 0 64 64" className={className} aria-hidden="true" focusable="false">
|
||||
{BARS.map((bar, i) =>
|
||||
reduced ? (
|
||||
<rect key={i} {...bar.to} opacity={i === 1 || i === 3 ? 0 : 1} rx={1.5} fill="currentColor" />
|
||||
) : (
|
||||
<motion.rect
|
||||
key={i}
|
||||
rx={1.5}
|
||||
fill="currentColor"
|
||||
initial={bar.from}
|
||||
animate={inView ? bar.to : bar.from}
|
||||
transition={{ duration: 0.7, delay: i * 0.08, ease: [0.22, 1, 0.36, 1] }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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<Size, string> = {
|
||||
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 (
|
||||
<span className={`font-sans font-bold tracking-tight leading-none ${sizeClass[size]} ${baseColor}`}>
|
||||
{before}
|
||||
<span data-accent="true" className={goldColor}>≡</span>
|
||||
{after}
|
||||
</span>
|
||||
<svg
|
||||
viewBox={`0 0 1000 ${showTagline ? TAG_BASELINE : MARK_HEIGHT}`}
|
||||
className={`font-sans h-auto ${baseColor} ${sizeClass[size]}`}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<path fill="currentColor" d={LETTERS} />
|
||||
<g data-accent="true" className="fill-dorado">
|
||||
{BARS.map((y) => (
|
||||
<rect key={y} x="529.8" y={y} width="124.9" height="21.1" rx="10.6" />
|
||||
))}
|
||||
</g>
|
||||
{showTagline && (
|
||||
// textLength pins the tagline to the width of the mark, so the lockup
|
||||
// holds its proportions whatever the rendered font does.
|
||||
<text x="0" y={TAG_BASELINE} textLength="1000" lengthAdjust="spacing" fontSize={TAG_SIZE} fill="currentColor">
|
||||
{pillars[0]}
|
||||
<tspan className="fill-dorado"> · </tspan>
|
||||
{pillars[1]}
|
||||
<tspan className="fill-dorado"> · </tspan>
|
||||
{pillars[2]}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -11,4 +11,22 @@ describe('<CTA />', () => {
|
||||
render(<CTA href="#contacto" label="x" />);
|
||||
expect(screen.getByRole('link').getAttribute('href')).toBe('#contacto');
|
||||
});
|
||||
it('renders the ghost variant outlined, so it reads as secondary', () => {
|
||||
render(<CTA href="#mecanismo" label="Ver cómo trabajo" variant="ghost" />);
|
||||
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(
|
||||
<>
|
||||
<CTA href="#a" label="a" variant="light" />
|
||||
<CTA href="#b" label="b" variant="ghost" />
|
||||
</>,
|
||||
);
|
||||
const [light, ghost] = Array.from(container.querySelectorAll('a'));
|
||||
expect(light.className).toContain('border-2');
|
||||
expect(ghost.className).toContain('border-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SimboloTresLineas } from '../SimboloTresLineas';
|
||||
|
||||
describe('<SimboloTresLineas />', () => {
|
||||
it('is decorative: hidden from assistive tech', () => {
|
||||
const { container } = render(<SimboloTresLineas />);
|
||||
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('renders five bars resolving toward the three-line symbol', () => {
|
||||
const { container } = render(<SimboloTresLineas />);
|
||||
expect(container.querySelectorAll('rect')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('takes its colour from currentColor, like the other brand graphics', () => {
|
||||
const { container } = render(<SimboloTresLineas />);
|
||||
container.querySelectorAll('rect').forEach((rect) => {
|
||||
expect(rect).toHaveAttribute('fill', 'currentColor');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,20 +3,33 @@ import { describe, it, expect } from 'vitest';
|
||||
import { Wordmark } from '../Wordmark';
|
||||
|
||||
describe('<Wordmark />', () => {
|
||||
it('renders the wordmark in lowercase', () => {
|
||||
it('exposes the brand name in lowercase as its accessible name', () => {
|
||||
render(<Wordmark />);
|
||||
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(<Wordmark />);
|
||||
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(<Wordmark />);
|
||||
const { container } = render(<Wordmark showTagline />);
|
||||
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(<Wordmark showTagline />);
|
||||
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(<Wordmark />);
|
||||
expect(container.querySelector('text')).toBeNull();
|
||||
expect(container.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 1000 153.14');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user