Primitivas y contenido del rediseño (escala tipográfica, Revelar, MarcaSeccion, Seccion, Antetitulo, SimboloMarca, Resplandor, ClaridadHero, sección Proceso de ocho etapas, lib/content.ts reestructurado en seis secciones con alias para las viejas). Mecanismo y FormaTrabajar se retiran. Encima, lo de esta sesión: - recorrido: creeer vuelve a responder cada situación en vez de repetirla (el alias apuntaba respuesta a situacion); respuestas en plural. Créditos de las seis fotos de México. - contacto: WhatsApp, email (con mailto:) y LinkedIn reales; iconos de las redes en el cierre y el footer (IconoRed, trazos de Simple Icons, CC0). - enlaces: lo externo abre en otra pestaña con noopener y aviso para lectores de pantalla (lib/enlaces.ts); mailto y anclas se quedan. - Proceso: las ocho tarjetas no aparecían nunca en móvil (amount 0.3 sobre una lista de ~3000px); ahora cada una se revela sola. El ≡ de la 08 ya no tapa un entregable, título equilibrado, etiqueta a 12px, sin elevar al hover lo que no es clicable. - Disciplinas: la frase «No son la promesa — son los medios» gana peso y se agrupa con título e ilustración; sin cortes huérfanos en móvil. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
5.3 KiB
TypeScript
131 lines
5.3 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Wordmark } from '@/components/primitives/Wordmark';
|
|
import { fadeInUp } from '@/lib/motion';
|
|
|
|
// One exchange of the page's conversation: the reader's situation, then what
|
|
// creeer does about it. The answer arrives as you scroll into the pair — its
|
|
// gold rule draws down and the block comes up to full contrast — so the section
|
|
// reads as being answered rather than as a list that happens to have answers.
|
|
//
|
|
// The sides never swap from one pair to the next. Each voice keeps its own
|
|
// margin, the way a conversation reads on a page; what alternates going down is
|
|
// situación → respuesta, not left → right. Flipping sides per pair would cost
|
|
// the reader the one cue that tells them who is speaking.
|
|
//
|
|
// Three cues carry that distinction, never colour alone: the speaker label, the
|
|
// column, and the rule hanging off each block.
|
|
//
|
|
// The answer text is in the DOM at all times, at every breakpoint — opacity,
|
|
// never `display: none` or `hidden`, so screen readers still announce it and
|
|
// the static export still ships it to search engines.
|
|
//
|
|
// `activo` latches: the section turns it on when the reader reaches the pair
|
|
// and never turns it off again. An answer that faded back out once you scrolled
|
|
// past would leave the page emptier the further you read, and would re-create
|
|
// the failure this project has already hit twice — content stranded at opacity
|
|
// zero with every test green, because jsdom has no layout and no scrolling.
|
|
//
|
|
// No motion.li here on purpose: Framer propagates variants through React
|
|
// context, so the two motion.div children still inherit the stagger from the
|
|
// <ul> the section wraps them in, even across this plain <li>.
|
|
|
|
export function ParDialogo({
|
|
n,
|
|
name,
|
|
situacion,
|
|
respuesta,
|
|
activo,
|
|
reducido,
|
|
onActivar,
|
|
}: {
|
|
n: number;
|
|
name: string;
|
|
situacion: string;
|
|
respuesta: string;
|
|
activo: boolean;
|
|
reducido: boolean;
|
|
onActivar: (n: number) => void;
|
|
}) {
|
|
const ref = useRef<HTMLLIElement>(null);
|
|
|
|
// The pair crossing the middle of the viewport is the one being answered.
|
|
// The negative margins collapse the root to a band across the centre, so
|
|
// "in view" means "where the reader is actually looking", not "anywhere on
|
|
// screen" — which with six pairs would fire all of them at once.
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el || reducido) return;
|
|
const observer = new IntersectionObserver(
|
|
([entrada]) => {
|
|
if (entrada.isIntersecting) onActivar(n);
|
|
},
|
|
{ rootMargin: '-45% 0px -45% 0px' },
|
|
);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [n, reducido, onActivar]);
|
|
|
|
// Reduced motion gets the resolved state outright: everything at full
|
|
// contrast, every rule drawn. Never the dimmed half of an animation that
|
|
// will not run.
|
|
const resuelto = reducido || activo;
|
|
|
|
return (
|
|
<li
|
|
ref={ref}
|
|
data-activo={resuelto ? 'true' : 'false'}
|
|
onMouseEnter={() => onActivar(n)}
|
|
onFocus={() => onActivar(n)}
|
|
className="grid gap-5 md:grid-cols-2 md:gap-12"
|
|
>
|
|
{/* Two nested layers on purpose, and they must stay nested. Framer writes
|
|
the resolved variant into the inline `style` — `opacity: 1` — and an
|
|
inline style beats a Tailwind class. Putting the reveal on the same
|
|
element as `variants` looked right in the markup and did nothing in the
|
|
browser: the answers were readable from the moment the section entered,
|
|
and only the gold rule, a transform Framer never touches, animated.
|
|
So: motion.div owns the entrance, the plain child owns the reveal. */}
|
|
<motion.div variants={fadeInUp}>
|
|
<div
|
|
className={`border-l-2 border-azul-profundo/25 pl-5 transition-opacity duration-500 motion-reduce:transition-none ${
|
|
resuelto ? 'opacity-100' : 'opacity-55'
|
|
}`}
|
|
>
|
|
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado-texto">
|
|
{String(n).padStart(2, '0')} · {name}
|
|
</p>
|
|
<p className="mt-3 font-sans font-bold text-xl md:text-2xl text-azul-profundo leading-snug">
|
|
«{situacion}»
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div variants={fadeInUp} className="md:mt-10">
|
|
<div
|
|
tabIndex={0}
|
|
aria-label={`Respuesta de creeer a: ${name}`}
|
|
className={`relative pl-5 transition-opacity duration-500 motion-reduce:transition-none ${
|
|
resuelto ? 'opacity-100' : 'opacity-0'
|
|
}`}
|
|
>
|
|
{/* The rule draws itself down as the answer arrives. A transform, not
|
|
a stroke: a dash pattern on a non-uniformly scaled SVG is exactly
|
|
what broke the hilo conductor, and this has to survive any
|
|
container it lands in. */}
|
|
<span
|
|
aria-hidden="true"
|
|
className={`absolute left-0 top-0 h-full w-0.5 bg-dorado origin-top transition-transform duration-700 ease-out motion-reduce:transition-none ${
|
|
resuelto ? 'scale-y-100' : 'scale-y-0'
|
|
}`}
|
|
/>
|
|
<Wordmark size="sm" />
|
|
<p className="mt-3 text-azul-profundo/70 leading-relaxed">{respuesta}</p>
|
|
</div>
|
|
</motion.div>
|
|
</li>
|
|
);
|
|
}
|