<!-- ==========================================================================
CARROSSEL DE PERFIS — trilho arrastável de pessoas, com esteira embaixo
Prefixo: pfl-
Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
Sem lib. O laço infinito, o arrasto e a inércia são ~150 linhas de vanilla.
TRÊS PEÇAS NUMA DOBRA SÓ:
1. coluna de texto à esquerda, com título display gigante;
2. trilho de cards que anda sozinho, sangra pela direita e responde ao
arrasto do mouse ou do dedo;
3. esteira de texto no rodapé da seção, em tinta fraca, que é o chão
da dobra e ao mesmo tempo o respiro para a seção seguinte.
SANGRIA (contrato §4). Não há .wrap aqui. Quem alinha a coluna de texto
à margem da página é --pfl-inset no estilo.css, que refaz a conta do
.wrap aplicada ao grid — mesmo recurso do midia/galeria-scroll-horizontal.
O trilho ocupa a segunda coluna do grid e vaza para fora dela; quem corta
é o overflow:hidden da seção. Nenhuma margem negativa, nenhum 100vw.
CLONES. O markup traz os perfis UMA vez. O script duplica a fila até dar
volta e marca as cópias com aria-hidden — quem usa leitor de tela ouve
quatro pessoas, não doze. Escrever as cópias à mão significaria manter a
bio de cada sócio em três lugares.
O CARD É FOCÁVEL de propósito (tabindex="0"): a bio aparece no hover, e
sem isso quem navega por teclado nunca chegaria nela. A bio já está no
DOM em qualquer caso — o painel é tratamento visual, não conteúdo
escondido. No toque, um toque abre e outro fecha.
========================================================================== -->
<sectionclass="sec-paper pfl"data-pfl>
<divclass="pfl-grid">
<divclass="pfl-head">
<h2class="display-xl ttl">Os sócios</h2>
<divclass="pfl-body">
<pclass="pfl-p"data-rv>
Aqui os sócios não representam a instituição — eles sentam do seu
lado da mesa.
</p>
<pclass="pfl-p"data-rvdata-d="1">
Cada um responde por uma frente da casa — carteira, sucessão,
proteção, estruturas no exterior — e toda recomendação passa pelo
comitê antes de chegar à sua política de investimentos. Nenhum deles
é remunerado por produto distribuído: a receita da casa é a taxa
previamente acordada com a família, e só ela.
</p>
</div>
</div>
<divclass="pfl-rail">
<ulclass="pfl-track"data-pfl-tracktabindex="0"aria-label="Sócios — arraste para o lado ou use as setas do teclado">
<liclass="pfl-item"data-rv>
<articleclass="pfl-card"tabindex="0"style="--pfl-focus:50% 30%">
<divclass="pfl-photo">
<imgclass="pfl-img"src="../../../showroom/demo/ph-4.webp"alt="Retrato de Helena Vasques"decoding="async">
<divclass="pfl-bio">
<pclass="pfl-bio-text">
Helena é economista de formação, com certificação de
planejadora financeira. Passou por mesa de private e depois
pela área de planejamento de uma gestora, até fundar a casa
com a tese de que assessorar e distribuir produto não cabem
na mesma empresa. Responde pelo wealth planning: traduz o
que a família quer da vida em política de investimentos
escrita, com horizonte, limite de risco e regra de saque. É
ela quem conduz o primeiro diagnóstico de cada cliente.
</p>
</div>
</div>
<spanclass="pfl-rule"></span>
<divclass="pfl-foot">
<h3class="pfl-name">Helena Vasques</h3>
<pclass="pfl-role">Fundadora · Wealth planning</p>
</div>
</article>
</li>
<liclass="pfl-item"data-rvdata-d="1">
<articleclass="pfl-card"tabindex="0"style="--pfl-focus:50% 40%">
<divclass="pfl-photo">
<imgclass="pfl-img"src="../../../showroom/demo/ph-7.webp"alt="Retrato de Bruno Sarmento"loading="lazy"decoding="async">
<divclass="pfl-bio">
<pclass="pfl-bio-text">
Bruno é engenheiro de formação, com mestrado em finanças.
Passou por mesa de renda variável e depois por uma gestora
independente, onde cuidou da alocação em fundos
multimercado. Responde pela estratégia de carteira: define
as classes de ativo, os pesos e as bandas de rebalanceamento
que o comitê revisa a cada trimestre. É quem explica, na
reunião, por que a carteira não seguiu a manchete do mês —
e por que ela não foi feita para seguir.
</p>
</div>
</div>
<spanclass="pfl-rule"></span>
<divclass="pfl-foot">
<h3class="pfl-name">Bruno Sarmento</h3>
<pclass="pfl-role">Sócio · Estratégia de carteira</p>
</div>
</article>
</li>
<liclass="pfl-item"data-rvdata-d="2">
<articleclass="pfl-card"tabindex="0"style="--pfl-focus:58% 12%">
<divclass="pfl-photo">
<imgclass="pfl-img"src="../../../showroom/demo/ph-3.webp"alt="Retrato de Clara Bittencourt"loading="lazy"decoding="async">
<divclass="pfl-bio">
<pclass="pfl-bio-text">
Clara é advogada de formação, com especialização em direito
de família e sucessões. Passou por escritório jurídico que
atendia grupos familiares e depois pelo wealth planning de
uma gestora, montando holdings e acordos de sócios.
Responde pelo planejamento sucessório: desenha a holding, a
doação em vida, o testamento e o seguro que paga o imposto
na hora certa. É a sócia que faz as perguntas incômodas na
primeira reunião — e a que continua atendendo o telefone
três anos depois, quando a resposta finalmente importa.
</p>
</div>
</div>
<spanclass="pfl-rule"></span>
<divclass="pfl-foot">
<h3class="pfl-name">Clara Bittencourt</h3>
<pclass="pfl-role">Sócia · Planejamento sucessório</p>
</div>
</article>
</li>
<liclass="pfl-item"data-rvdata-d="3">
<articleclass="pfl-card"tabindex="0"style="--pfl-focus:50% 34%">
<divclass="pfl-photo">
<imgclass="pfl-img"src="../../../showroom/demo/ph-5.webp"alt="Retrato de Otávio Rangel"loading="lazy"decoding="async">
<divclass="pfl-bio">
<pclass="pfl-bio-text">
Otávio é contador de formação, com pós-graduação em
tributação internacional. Passou por mesa de câmbio e depois
por uma gestora com veículo no exterior, onde cuidou de
reporte e enquadramento fiscal. Responde pelas estruturas
internacionais: a conta lá fora, o fundo, o trust quando
faz sentido e a declaração que mantém tudo isso em ordem
aqui. É quem diz não quando a estrutura custa mais do que
resolve.
</p>
</div>
</div>
<spanclass="pfl-rule"></span>
<divclass="pfl-foot">
<h3class="pfl-name">Otávio Rangel</h3>
<pclass="pfl-role">Sócio · Estruturas internacionais</p>
</div>
</article>
</li>
</ul>
</div>
</div>
<!-- ESTEIRA. Puramente gráfica: aria-hidden, sem seleção e sem ponteiro.
Dois conjuntos idênticos no trilho, que anda -50% — exatamente a
largura de um deles. É a mesma receita do prova/marquee-logos. -->
<divclass="pfl-strip"aria-hidden="true">
<divclass="pfl-strip-rail">
<divclass="pfl-strip-set">
<spanclass="pfl-strip-word">Liberta Wealth</span>
<spanclass="pfl-strip-word">Liberta Wealth</span>
<spanclass="pfl-strip-word">Liberta Wealth</span>
</div>
<divclass="pfl-strip-set">
<spanclass="pfl-strip-word">Liberta Wealth</span>
<spanclass="pfl-strip-word">Liberta Wealth</span>
<spanclass="pfl-strip-word">Liberta Wealth</span>
</div>
</div>
</div>
</section>
estilo.css · 296 linhas · 12,4 KB
/* ==========================================================================
CARROSSEL DE PERFIS — prefixo pfl-
Coluna de texto à esquerda, trilho de cards de pessoa que anda sozinho e
sangra pela direita, esteira de texto no chão da seção.
Tokens usados:
--pad --max conta do recuo que alinha a coluna de texto à página
--display --ease tipografia e curva do projeto
--sec-gap respiro vertical da dobra
--accent --dark painel da bio: fundo de marca, texto escuro
--fg --fg-2 --rule título, corpo e filete, herdados do escopo de tom
--radius-media canto da foto
A BIO É UMA ILHA DE MARCA. O painel que cobre a foto no hover pinta
--accent e escreve em --dark, e não em --fg: assim ele lê igual numa
seção clara e numa escura. O que está fora do painel (nome, cargo,
filete) continua lendo o escopo de tom da seção.
========================================================================== */.pfl{
/* RECUO DA COLUNA (contrato §4). Não existe .wrap nesta dobra: o trilho
precisa vazar para a direita, e um .wrap o prenderia. Esta variável
refaz a conta do .wrap — metade da sobra mais o respiro — e é aplicada
ao grid, não à página. O 100% é a largura da SEÇÃO.
tela estreita -> a coluna já encosta nas bordas, vale --pad
tela larga -> (100% - --max) / 2 + --pad */--pfl-inset:max(var(--pad),calc((100% - var(--max)) / 2 + var(--pad)));
--pfl-col:clamp(280px,32vw,468px); /* coluna de texto */--pfl-gap:clamp(32px,5.5vw,80px); /* vão entre texto e trilho */--pfl-card:clamp(258px,33vw,485px); /* largura do card */--pfl-space:clamp(20px,4.5vw,65px); /* vão entre um card e o próximo *//* Corpo do título. Mesmo teto e mesmo motivo do
conteudo/apresentacao-foto-alternada: o original roda 160px numa display
condensada, e uma display de largura normal estoura a coluna nesse corpo. */--pfl-title:clamp(46px,6vw,104px);
--display-xl:var(--pfl-title);
/* Velocidade da esteira de cards, em PIXELS POR SEGUNDO. Lida pelo script,
que faz a conta por tempo e não por quadro — assim a dobra anda igual em
tela de 60Hz e de 120Hz. */--pfl-speed:26;
--pfl-strip-size:clamp(46px,10vw,150px); /* corpo da esteira de texto */--pfl-strip-gap:clamp(24px,3.4vw,50px);
--pfl-strip-dur:38s;
/* Chão da seção. A esteira ocupa o lugar do respiro de baixo: ela é a
última coisa da dobra e encosta na seção seguinte, que é o efeito.
Numa página SEM a esteira, ponha --pfl-floor:var(--sec-gap) e o respiro
volta ao normal. */--pfl-floor:0px;
position:relative;
/* O trilho vaza para fora da segunda coluna do grid; quem corta é aqui. */overflow:hidden;
/* Não usa .sec-pad porque o rodapé desta dobra não é vazio — ver
--pfl-floor acima. O topo continua lendo --sec-gap, que é a fonte
única do ritmo entre dobras (§4). */padding-block:var(--sec-gap) var(--pfl-floor);
}
.pfl-grid{
display:grid;
grid-template-columns:minmax(0,var(--pfl-col)) minmax(0,1fr);
gap:var(--pfl-gap);
align-items:center;
/* Único padding-inline do componente, e ele é do GRID, não da página:
alinha a coluna de texto à margem sem prender o trilho. Mesma solução
do midia/galeria-scroll-horizontal. */padding-inline-start:var(--pfl-inset);
}
/* ---------------------------------------------------------------- coluna de texto */.pfl-head{display:flex;flex-direction:column;gap:clamp(22px,3vw,32px);min-width:0}
/* O título é a primitiva .display-xl do core (ver core/base.css). Era a
segunda das três cópias que dispararam a promoção do §6. */.pfl-body{display:flex;flex-direction:column;gap:20px}
.pfl-p{font-size:16px;line-height:1.5;color:var(--fg-2)}
/* ---------------------------------------------------------------- trilho *//* min-width:0 é o que permite a coluna encolher abaixo do conteúdo — sem
ele, o grid dá ao trilho a largura da fila inteira e a coluna de texto
some.
O overflow:hidden aqui é OBRIGATÓRIO, e não é o mesmo da seção. O trilho
anda para a esquerda: sem este recorte, o card que sai de cena continua
desenhado e passa POR CIMA do título e dos parágrafos. Quem corta pela
direita continua sendo a seção — esta coluna do grid vai até a borda da
página, então o recorte da direita cai exatamente no mesmo lugar.
O padding-block é o preço do recorte: overflow:hidden corta nos dois
eixos, e sem esses 6px o anel de foco do card — que o core desenha 3px
para fora da caixa — sumiria em cima e embaixo. */.pfl-rail{min-width:0;overflow:hidden;padding-block:6px}
.pfl-track{
display:flex;
gap:var(--pfl-space);
width:max-content;
cursor:grab;
/* O gesto horizontal é nosso; o vertical continua sendo da página. Sem
isto, arrastar de lado no celular trava a rolagem. */touch-action:pan-y;
/* Arrastar sobre texto e foto selecionaria os dois. */user-select:none;-webkit-user-select:none;
will-change:transform;
}
.pfl-track.is-dragging{cursor:grabbing}
/* O trilho é focável (tabindex no markup) para as setas funcionarem. O anel
do core desenha 3px para fora; sem este respiro, a seção o cortaria. */.pfl-track:focus-visible{outline-offset:-2px}
.pfl-item{flex:00var(--pfl-card);min-width:0}
/* ---------------------------------------------------------------- card */.pfl-card{
display:flex;flex-direction:column;
gap:clamp(20px,3vw,40px);
/* O card não tem fundo nem borda: ele é foto, filete e duas linhas sobre
o papel da seção. Qualquer caixa aqui brigaria com a esteira do rodapé,
que é justamente o que dá corpo à dobra. */
}
/* O card é focável (tabindex no markup) e o anel do core fica: chegar por
teclado abre a bio, mas sem o anel não há como saber ONDE se está na
fileira. Clicar com o mouse não acende nada — :focus-visible só dispara
para foco por teclado. *//* 1.08 é a proporção do original (485 × 449): quase quadrado, um fio mais
largo que alto. Retrato fechado dentro de caixa larga é o enquadramento
que faz a fileira de rostos ler como fileira, e não como cartas. */.pfl-photo{
position:relative;
aspect-ratio:1.08;
overflow:hidden;
border-radius:var(--radius-media);
background:var(--rule);
}
/* O corte é por card, não por regra: retrato de estúdio tem o rosto no terço
de cima, foto de ambiente tem no meio, e cravar um valor único faria uma
das duas famílias sair decapitada. Cada card declara o seu no markup
(style="--pfl-focus:50% 30%"), como no conteudo/pilares-texto-na-foto —
o que também impede o acoplamento entre a ORDEM dos cards e o CSS. */.pfl-img{
position:absolute;inset:0;
width:100%;height:100%;object-fit:cover;
object-position:var(--pfl-focus,50%32%);
}
/* HOVER NÃO MEXE NA IMAGEM (CLAUDE.md §6, regra de 2026-09-29). O zoom de
foto sob o ponteiro saiu: escala e deslocamento de mídia no hover mexem
em recorte e composição, e é o que quebra a página quando o componente
é colado noutro lugar. O retorno é o painel da bio, que cobre a
foto em opacidade — ela mesma não se move. *//* PAINEL DA BIO. Cobre a foto — não o card inteiro. No original ele cobre
tudo e apaga o nome da pessoa justamente enquanto se lê a bio dela; aqui
o nome e o cargo continuam embaixo, legíveis, e o painel entra só onde a
frase "aparece mais texto na imagem" quer dizer alguma coisa.
pointer-events:none em repouso: um painel invisível por cima da foto
comeria o hover dela e o card nunca acenderia. *//* O PAINEL SÓ ESMAECE — não sobe e não desfoca. Os dois efeitos foram
testados nele e os dois abrem fresta escura sobre a foto:
filter:blur() desfia as bordas do próprio elemento, então o retângulo
sólido fica MENOR que a caixa enquanto o desfoque cai a zero, e a foto
aparece num halo em volta;
transform:translateY() desliza o painel para baixo dentro de uma caixa
que corta (a .pfl-photo tem overflow:hidden), deixando à mostra uma
tira da foto no topo durante toda a subida.
Quem entra com blur e subida é o TEXTO, logo abaixo — ali o efeito é o
que o §5 do contrato pede e não há borda para desfiar, porque o
parágrafo tem o padding do painel inteiro em volta. */.pfl-bio{
position:absolute;inset:0;z-index:2;
display:flex;align-items:flex-start;
padding:clamp(18px,2.6vw,32px);
background:var(--accent);
color:var(--dark);
opacity:0;pointer-events:none;
transition:opacity .45svar(--ease);
}
.pfl-card:hover .pfl-bio,
.pfl-card:focus-visible .pfl-bio,
.pfl-card.is-open .pfl-bio{opacity:1}
/* 16px é o corpo do original, e é o teto: o painel tem a altura da foto e
não rola. Quem escreve bio mais longa que o padrão editorial da ficha
perde o fim dela, cortado pelo overflow da caixa.
O atraso é o que dá ordem à cena: primeiro o painel cobre a foto, e só
então a bio assenta. Simultâneos, os dois viram um borrão só. */.pfl-bio-text{
font-size:clamp(13px,1.2vw,16px);
line-height:1.5;
transform:translateY(14px);filter:blur(8px);
transition:transform .5svar(--ease) .06s,
filter .5svar(--ease) .06s;
}
.pfl-card:hover .pfl-bio-text,
.pfl-card:focus-visible .pfl-bio-text,
.pfl-card.is-open .pfl-bio-text{transform:none;filter:blur(0)}
/* Filete entre a foto e a assinatura. Era rgba(0,0,0,.2) cravado no
original; --rule é exatamente esse degrau, e vem do escopo de tom. */.pfl-rule{display:block;height:1px;background:var(--rule)}
.pfl-foot{display:flex;flex-direction:column;gap:8px}
/* O nome é fonte de TEXTO em corpo grande, não display: quem grita nesta
dobra é o título e a esteira. Peso 400 é o teto — a nota do tema explica
que pedir mais de uma face que só tem Light faz o navegador engordar a
letra artificialmente. O contraste com o cargo vem do corpo, não do peso,
e é assim no original também. */.pfl-name{
font-family:var(--sans);font-weight:400;
font-size:clamp(22px,2.2vw,32px);line-height:1.2;letter-spacing:-.02em;
color:var(--fg);
}
.pfl-role{font-size:16px;line-height:1.5;color:var(--fg-2)}
/* ---------------------------------------------------------------- esteira *//* Tinta a 10%: é textura de fundo, não título. Acima disso ela disputa com
o nome das pessoas e a dobra passa a ter dois assuntos. */.pfl-strip{
overflow:hidden;
margin-block-start:clamp(56px,8vh,100px);
opacity:.1;
pointer-events:none;
user-select:none;-webkit-user-select:none;
}
/* Dois conjuntos idênticos, o trilho anda -50% — a largura exata de um
deles. É o que fecha o laço sem salto (mesma receita do
prova/marquee-logos). */.pfl-strip-rail{
display:flex;width:max-content;
animation:pflStrip var(--pfl-strip-dur) linear infinite;
}
.pfl-strip-set{
display:flex;gap:var(--pfl-strip-gap);
/* Mantém o vão na emenda entre as duas cópias. */padding-inline-end:var(--pfl-strip-gap);
}
.pfl-strip-word{
font-family:var(--display);font-weight:700;
font-size:var(--pfl-strip-size);
line-height:1;letter-spacing:-.02em;
text-transform:uppercase;
color:var(--fg);
white-space:nowrap;
}
@keyframes pflStrip{
from{transform:translateX(0)}
to{transform:translateX(-50%)}
}
/* ---------------------------------------------------------------- movimento reduzido *//* Para a esteira de texto e apaga a transição do painel. O trilho de cards
é tratado no script.js: sem movimento reduzido ele anda sozinho; com,
fica parado e só o arrasto o move — gesto pedido pela pessoa não é
movimento imposto. */@media(prefers-reduced-motion:reduce){
.pfl-strip-rail{animation:none}
.pfl-bio{transition:none}
.pfl-bio-text{transition:none;transform:none;filter:none}
}
/* ---------------------------------------------------------------- toque *//* Sem ponteiro não existe hover. A bio continua alcançável — o script abre e
fecha no toque — mas o cursor de arrasto não faz sentido. */@media(hover:none){
.pfl-track{cursor:default}
}
/* ---------------------------------------------------------------- mobile */@media(max-width:900px){
/* Uma coluna só. O padding-inline-start do grid continua valendo, então
texto e primeiro card seguem alinhados na mesma margem e o trilho
continua vazando pela direita — sem margem negativa e sem 100vw. */.pfl-grid{grid-template-columns:1fr;gap:clamp(32px,5vh,48px)}
}
@media(max-width:560px){
.pfl{--pfl-title:clamp(38px,12vw,58px);--pfl-space:18px}
.pfl-body{gap:16px}
.pfl-card{gap:18px}
}
script.js · 370 linhas · 14,2 KB
/* ==========================================================================
CARROSSEL DE PERFIS — laço infinito, arrasto e inércia.
A esteira de texto do rodapé é CSS e não passa por aqui. Este script cuida
só do trilho de cards, e faz cinco coisas:
1. DUPLICA a fila até ela dar mais de uma volta na tela, marcando as
cópias com aria-hidden — o laço precisa de fila sobrando, o leitor de
tela não precisa ouvir a mesma pessoa três vezes;
2. ANDA sozinho, a --pfl-speed pixels por segundo, com a conta feita por
TEMPO e não por quadro (60Hz e 120Hz andam igual);
3. ARRASTA com mouse ou dedo, com inércia ao soltar;
4. PARA quando o ponteiro entra ou o foco chega — é quando a bio abre, e
um card que desliza enquanto se lê é um card que não se lê;
5. ABRE E FECHA a bio no toque, para quem não tem hover.
Um rAF só, e ele mesmo se desliga quando não há nada para fazer: fora da
tela, sob o ponteiro, ou com movimento reduzido e sem arrasto em curso.
Nenhum listener de scroll — quem observa a entrada é o IntersectionObserver.
MOVIMENTO REDUZIDO: o trilho não anda sozinho e não arremessa ao soltar.
O arrasto continua funcionando, porque gesto pedido pela pessoa não é
movimento imposto a ela.
========================================================================== */
(function () {
var raizes = document.querySelectorAll('[data-pfl]');
if (!raizes.length) return; // guard/* A flag do core é a fonte. O fallback existe para o componente colado
sozinho, sem motion.js — e repete a regra do ?motion=on. */function reduzido() {
if (window.LIB && typeof window.LIB.reduce === 'boolean') return window.LIB.reduce;
return matchMedia('(prefers-reduced-motion: reduce)').matches &&
location.search.indexOf('motion=on') < 0;
}
function iniciar(raiz) {
var trilho = raiz.querySelector('[data-pfl-track]');
if (!trilho) return;
var originais = [].slice.call(trilho.children);
if (!originais.length) return;
var reduz = reduzido();
/* Velocidade mora no CSS (--pfl-speed), em px/s, junto do resto dos
ajustes do componente. Com movimento reduzido ela é zero. */var base = reduz ? 0
: (parseFloat(getComputedStyle(raiz).getPropertyValue('--pfl-speed')) || 26);
var pos = 0; /* deslocamento do trilho, em px */var largura = 0; /* largura de UM conjunto, vão incluído */var passoCard = 0; /* distância de um card ao próximo */var vel = 0; /* inércia do arrasto, em px por quadro */var salto = 0; /* px que faltam do pulo pedido pelo teclado */var parado = false; /* ponteiro em cima ou foco dentro */var dentro = false; /* dobra visível */var laco = 0;
var antes = 0;
var copias = 1;
var arrastando = false, ponteiro = -1, xAnterior = 0, dxUltimo = 0, andou = 0;
/* ------------------------------------------------------------ medidas *//* A largura de um conjunto é a distância entre o primeiro original e o
primeiro clone. Medir assim inclui o vão automaticamente — somar
larguras na mão erraria por um gap. offsetLeft não enxerga o transform
do pai, que é exatamente o que se quer aqui. */function medir() {
if (copias < 2) return0;
return trilho.children[originais.length].offsetLeft - originais[0].offsetLeft;
}
function medirPasso() {
if (trilho.children.length < 2) return0;
return trilho.children[1].offsetLeft - trilho.children[0].offsetLeft;
}
/* Duplica a fila.
A conta NÃO é "encher duas telas". O que o laço precisa é: além do
conjunto que está em cena, sobrar fila suficiente para cobrir a janela
inteira do trilho — senão, no ponto em que o deslocamento dá a volta,
aparece vazio entre o fim da fila e o começo dela. Daí
(copias - 1) * conjunto >= janela.
Medir a largura total em vez disso quebraria justamente no caso de
time grande: dez perfis já passam de duas telas com UM conjunto só, o
clone nunca seria criado, não haveria o que medir e o trilho correria
até o fim da fila e travaria lá.
O primeiro clone é obrigatório por um segundo motivo: é a distância
entre ele e o original que dá a medida do conjunto (ver medir()).
O teto de 12 conjuntos é rede contra um erro de medida virar laço
infinito de clonagem. */function garantir() {
var janela = raiz.getBoundingClientRect().width + 240;
var conjunto;
while (copias < 12) {
if (copias >= 2) {
conjunto = medir();
if (conjunto > 0 && (copias - 1) * conjunto >= janela) break;
}
for (var i = 0; i < originais.length; i++) {
var copia = originais[i].cloneNode(true);
copia.setAttribute('aria-hidden', 'true');
/* Sem data-rv: o clone não é observado pelo core (ele nasceu
depois da varredura) e ficaria invisível para sempre. */
copia.removeAttribute('data-rv');
copia.removeAttribute('data-d');
/* aria-hidden com filho focável é erro de acessibilidade: o foco
entraria num pedaço da página que o leitor de tela não anuncia. */var focaveis = copia.querySelectorAll('[tabindex]');
for (var f = 0; f < focaveis.length; f++) focaveis[f].setAttribute('tabindex', '-1');
trilho.appendChild(copia);
}
copias++;
}
}
function remedir() {
garantir();
largura = medir();
passoCard = medirPasso();
}
/* ------------------------------------------------------------ rolagem do recorte *//* O .pfl-rail recorta com overflow:hidden, e isso faz dele um contêiner
de ROLAGEM aos olhos do navegador — mesmo sem barra à vista. Duas
coisas mexem nele por fora do nosso controle:
- chegar num card por Tab: o navegador rola o recorte para trazer à
vista o que recebeu foco, e faz isso DEPOIS do focusin (medido,
não suposto — tratar dentro do focusin é cedo demais);
- gesto horizontal de trackpad ou shift+roda sobre o trilho.
Nos dois casos a fila passa a ter DUAS posições somadas — a nossa, no
transform, e a do navegador, no scrollLeft — e a segunda nunca volta a
zero sozinha: os cards ficam deslocados para sempre.
O conserto é converter uma na outra. O que o navegador rolou vira
deslocamento nosso e o recorte volta a zero; no mesmo quadro o
resultado visual é idêntico, e sobra uma fonte só de verdade.
De brinde, o gesto horizontal do trackpad passa a mover o carrossel,
que é o que quem faz o gesto está pedindo. */var caixa = trilho.parentNode;
function absorverRolagem() {
if (!caixa) return;
if (caixa.scrollTop) caixa.scrollTop = 0;
var dx = caixa.scrollLeft;
if (!dx) return;
caixa.scrollLeft = 0;
pos += dx;
}
/* Dois gatilhos de propósito, e não por desconfiança do evento: o
listener é quem ACORDA o laço quando ele está dormindo (parado sob o
ponteiro, ou movimento reduzido); a chamada dentro do passo() é quem
garante que nenhum quadro seja desenhado com as duas posições somadas,
venha o evento antes ou depois. */if (caixa) {
caixa.addEventListener('scroll', function () {
absorverRolagem();
ligar(); /* um quadro basta: passo() escreve e se desliga */
}, { passive: true });
}
/* ------------------------------------------------------------ laço *//* O rAF só continua enquanto houver o que mover. Sem isto, uma dobra
parada sob o ponteiro — ou uma página inteira em movimento reduzido —
gastaria um quadro por vez para escrever o mesmo transform. */function ocupado() {
return arrastando || vel !== 0 || salto !== 0 || (!parado && base > 0) ||
(caixa && caixa.scrollLeft !== 0);
}
function passo(agora) {
absorverRolagem();
/* Teto de 50ms: voltar de uma aba em segundo plano daria um dt enorme
e o trilho saltaria meia volta de uma vez. */var dt = antes ? Math.min((agora - antes) / 1000, .05) : 0;
antes = agora;
if (!arrastando) {
if (salto) {
var fatia = salto * .16;
if (Math.abs(salto) < .6) fatia = salto;
pos += fatia;
salto -= fatia;
} elseif (!parado) {
pos += base * dt;
}
if (vel) {
pos += vel;
vel *= .93;
if (Math.abs(vel) < .03) vel = 0;
}
}
/* O laço: o deslocamento vive sempre dentro de um conjunto. while e
não if — um arremesso forte pode passar de uma volta num quadro só. */if (largura > 0) {
while (pos >= largura) pos -= largura;
while (pos < 0) pos += largura;
}
trilho.style.transform = 'translate3d(' + (-pos) + 'px,0,0)';
laco = (dentro && ocupado()) ? requestAnimationFrame(passo) : 0;
}
function ligar() {
if (laco || !dentro) return;
antes = 0;
laco = requestAnimationFrame(passo);
}
function desligar() {
if (!laco) return;
cancelAnimationFrame(laco);
laco = 0;
}
/* ------------------------------------------------------------ arrasto *//* O navegador tem um arrasto PRÓPRIO para imagem: puxar uma foto descola
dela um fantasma translúcido e o gesto vira um drag-and-drop de
arquivo — o trilho fica parado e o cursor mostra o ícone de "soltar
aqui". setPointerCapture não impede: são dois mecanismos separados,
e o nativo nasce do dragstart, que só se cancela cancelando.
É o irmão do problema que o user-select:none resolve no CSS. Os dois
existem pelo mesmo motivo: dentro de um trilho arrastável, todo gesto
de puxar pertence ao trilho. */
trilho.addEventListener('dragstart', function (e) { e.preventDefault(); });
trilho.addEventListener('pointerdown', function (e) {
if (typeof e.button === 'number' && e.button !== 0) return;
arrastando = true;
andou = 0;
vel = 0;
salto = 0;
ponteiro = e.pointerId;
xAnterior = e.clientX;
dxUltimo = 0;
trilho.classList.add('is-dragging');
/* Captura para o arrasto sobreviver ao ponteiro sair do trilho. */try { trilho.setPointerCapture(ponteiro); } catch (err) {}
ligar();
});
trilho.addEventListener('pointermove', function (e) {
if (!arrastando || e.pointerId !== ponteiro) return;
var dx = e.clientX - xAnterior;
xAnterior = e.clientX;
dxUltimo = dx;
andou += Math.abs(dx);
pos -= dx;
});
function soltar(e) {
if (!arrastando) return;
if (e && e.pointerId !== undefined && e.pointerId !== ponteiro) return;
arrastando = false;
trilho.classList.remove('is-dragging');
try { trilho.releasePointerCapture(ponteiro); } catch (err) {}
/* O último deslocamento vira arremesso e decai. Com movimento
reduzido o trilho para onde a mão soltou. */
vel = reduz ? 0 : -dxUltimo * .9;
ligar();
}
trilho.addEventListener('pointerup', soltar);
trilho.addEventListener('pointercancel', soltar);
/* ------------------------------------------------------------ bio no toque */function cardDe(no) {
while (no && no !== trilho) {
if (no.classList && no.classList.contains('pfl-card')) return no;
no = no.parentNode;
}
returnnull;
}
function abrir(card) {
var abertos = trilho.querySelectorAll('.pfl-card.is-open');
for (var i = 0; i < abertos.length; i++) {
if (abertos[i] !== card) abertos[i].classList.remove('is-open');
}
card.classList.toggle('is-open');
}
/* 6px de tolerância: um clique sempre treme um pouco, e um arrasto de
verdade passa longe disso. Sem o limiar, arrastar a fila abriria a bio
do card onde a mão soltou. */
trilho.addEventListener('click', function (e) {
if (andou > 6) return;
var card = cardDe(e.target);
if (card) abrir(card);
});
/* ------------------------------------------------------------ teclado */
trilho.addEventListener('keydown', function (e) {
var card = cardDe(e.target);
if (card && (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar')) {
e.preventDefault();
abrir(card);
return;
}
var direcao = 0;
if (e.key === 'ArrowRight') direcao = 1;
elseif (e.key === 'ArrowLeft') direcao = -1;
elsereturn;
e.preventDefault();
vel = 0;
salto += direcao * (passoCard || 300);
ligar();
});
/* ------------------------------------------------------------ pausa *//* mouseenter/mouseleave e não pointerenter: no toque não existe hover, e
pausar ali deixaria o trilho parado desde o primeiro toque. */
trilho.addEventListener('mouseenter', function () { parado = true; });
trilho.addEventListener('mouseleave', function () { parado = false; ligar(); });
trilho.addEventListener('focusout', function () { parado = false; ligar(); });
/* Chegar por Tab também acorda o laço: o navegador vai rolar o recorte
logo em seguida, e é absorverRolagem() quem devolve isso para o
deslocamento. Ver o bloco "rolagem do recorte". */
trilho.addEventListener('focusin', function () { parado = true; ligar(); });
/* ------------------------------------------------------------ tela */var pedido = 0;
addEventListener('resize', function () {
if (pedido) return;
/* Uma medida por quadro. garantir() e medir() leem layout, e chamar os
dois a cada evento de resize trava a janela ao redimensionar. */
pedido = requestAnimationFrame(function () {
pedido = 0;
remedir();
});
}, { passive: true });
var io = new IntersectionObserver(function (entradas) {
for (var i = 0; i < entradas.length; i++) {
dentro = entradas[i].isIntersecting;
if (dentro) ligar(); else desligar();
}
}, { threshold: 0 });
remedir();
io.observe(raiz);
}
for (var r = 0; r < raizes.length; r++) iniciar(raizes[r]);
})();
Três peças numa dobra só. À esquerda, uma coluna estreita com título display gigante e dois parágrafos — o argumento. À direita, um trilho de cards de pessoa que anda sozinho devagar, sangra pela borda direita da tela e obedece ao arrasto do mouse ou do dedo, com inércia ao soltar. Cada card é foto quase quadrada, um filete e duas linhas: nome e cargo. Passar o ponteiro sobre a foto faz um painel na cor de marca subir e cobri-la com a bio da pessoa. No chão da seção, uma esteira de texto gigante em tinta a 10% atravessa a dobra e encosta na seção seguinte.
Resolve a página de "quem faz" quando o time não cabe numa grade e cada pessoa tem história para contar. A grade obriga a escolher entre mostrar poucos com bio ou muitos sem. O trilho resolve as duas: mostra três de cada vez, tem quantos precisar, e a bio fica guardada até alguém querer.
Quando usar
Sócios, conselho, fundadores, especialistas — de 3 a 10 pessoas, cada uma com bio de verdade.
Quando o texto da dobra é um argumento (o que essas pessoas fazem por você) e não um rótulo ("nosso time").
Quando a página aguenta uma dobra alta: com a esteira, esta seção passa de 900px em desktop.
Quando não usar
Menos de três pessoas: o trilho não tem o que percorrer e o laço fica óbvio. Use conteudo/grid-equipe.
Time inteiro sem bio, só foto e cargo: a grade lê melhor e não esconde ninguém atrás de um gesto.
Quando a fila é de cases ou produtos e cada item precisa levar a algum lugar: midia/galeria-scroll-horizontal, onde o card é um <a> de verdade e o encaixe é nativo.
Uma linha, duas palavras. Caixa alta é do CSS — escreva normal.
Parágrafo 1
A imagem que resume a postura do time. Uma frase, no máximo duas linhas.
Parágrafo 2
O que eles entregam na prática. Quatro a seis linhas.
Nome
Nome e sobrenome, sem título acadêmico.
Cargo
Uma linha, até 60 caracteres. Duas linhas desalinham a fileira inteira.
Bio
380 a 620 caracteres. Abaixo disso o painel fica vazio; acima, o texto passa da altura da foto e é cortado.
O que mantém a fileira coesa é o paralelismo: se uma bio começa por formação, todas começam. Se uma menciona a empresa anterior, todas mencionam. Bio de tamanhos muito diferentes fica evidente porque os painéis têm exatamente a mesma altura.
Especificação dos assets
Formato e dimensão mínima: 900×1200 ou maior, WebP. O quadro exibido é 1.08 (quase quadrado) — o object-fit:cover corta em cima e embaixo.
Peso alvo: até 160 KB por foto.
Composição / enquadramento: rosto no terço superior, olhando para a câmera ou para o lado de dentro da fileira. Fundo escuro e liso, igual em todas.
O que não pode ter: recortes em fundo branco misturados com fotos de ambiente, nem uma pessoa de corpo inteiro no meio de retratos fechados.
Respiro abaixo da esteira. 0px no padrão — a esteira é o chão da dobra.
.is-open
No card: bio aberta. Quem põe e tira é o script, no clique.
.is-dragging
No trilho, durante o arrasto. Só troca o cursor.
Variações
sem esteira — apague .pfl-strip e ponha --pfl-floor:var(--sec-gap).
esteira com a marca — troque o texto dos .pfl-strip-word; os dois conjuntos têm de continuar idênticos.
trilho parado — --pfl-speed:0.
em seção escura — .sec-dark no lugar de .sec-paper.
cards menores — --pfl-card:clamp(220px,26vw,340px) para quatro de uma vez.
Gotchas
O título é a primitiva .display-xl do core, não uma regra desta pasta. As seis linhas que desenhavam o título aqui eram idênticas às do conteudo/apresentacao-foto-alternada e às do conteudo/manifesto-com-imagens; na terceira cópia o §6 manda promover, e foi o que se fez. --pfl-title continua sendo o gancho público desta dobra e alimenta o parafuso --display-xl da primitiva — quem só quiser trocar o corpo não precisa saber que a primitiva existe.
O markup traz cada pessoa uma vez; o script duplica. As cópias saem com aria-hidden="true" e tabindex="-1", e sem data-rv — o clone nasce depois da varredura do core e, se mantivesse o atributo, ficaria invisível para sempre. Não escreva as cópias à mão: seriam três lugares para manter a mesma bio.
A conta do número de cópias não é "encher a tela". É (cópias − 1) × conjunto ≥ janela: além do conjunto que está em cena, tem de sobrar fila para cobrir a janela inteira do trilho. Medir largura total quebraria justamente com time grande — dez perfis já passam de duas telas com um conjunto só, o clone nunca seria criado, e sem clone não há como medir o conjunto nem por onde dar a volta.
O recorte do trilho é um contêiner de rolagem, mesmo sem barra.overflow:hidden não impede o navegador de rolar: ao dar foco por Tab a um card fora da janela, ele rola o .pfl-rail — e faz isso depois do focusin. Como quem move a fila é o transform, a página passaria a ter duas posições somadas, e a do navegador não volta sozinha. O script lê esse scrollLeft, soma ao deslocamento e zera o recorte, no mesmo quadro. Efeito colateral bem-vindo: gesto horizontal de trackpad e shift+roda passam a mover o carrossel.
--pfl-speed é px por segundo, não por quadro. O script multiplica por dt. Um valor pensado para 60Hz numa tela de 120Hz andaria o dobro se a conta fosse por quadro — por isso não é.
O trilho para quando o ponteiro entra. Não é enfeite: é a condição para a bio ser legível. Se --pfl-speed for alto e a pausa parecer brusca, baixe a velocidade — não tire a pausa.
Sem .wrap nesta dobra, de propósito. O trilho precisa vazar para a direita, e um .wrap o prenderia. Quem alinha a coluna de texto é --pfl-inset aplicado ao grid, e quem corta o vazamento é o overflow:hidden da seção. Nenhuma margem negativa e nenhum 100vw (contrato §4).
--pfl-floor:0px é o que faz a esteira encostar na seção seguinte. A dobra abre mão do respiro de baixo porque a esteira ocupa esse lugar — ela é tinta a 10%, não conteúdo. Ao remover a esteira, devolva o respiro pela variável, senão as duas seções colam.
O painel da bio não sobe e não desfoca — só esmaece. Os dois efeitos foram testados nele e os dois abrem uma fresta escura sobre a foto: filter:blur() desfia as bordas do próprio elemento, então o retângulo sólido fica menor que a caixa enquanto o desfoque cai a zero; e translateY() desliza o painel dentro de uma caixa que corta, deixando à mostra uma tira da foto no topo durante toda a subida. Quem entra com blur e subida é o .pfl-bio-text, que tem o padding do painel inteiro em volta e não faz borda com nada. Se alguém "consertar" devolvendo o efeito ao painel, as frestas voltam.
O arrasto nativo de imagem é cancelado no dragstart. Sem isso, puxar uma foto descola dela um fantasma translúcido, o gesto vira drag-and-drop de arquivo e o trilho fica parado — foi o primeiro defeito que apareceu no uso real. setPointerCapture não resolve: são dois mecanismos separados, e o nativo só morre cancelando o evento. É o irmão do user-select:none do CSS, e pelo mesmo motivo: dentro de um trilho arrastável, todo gesto de puxar pertence ao trilho.
user-select:none no trilho impede selecionar a bio. É o preço do arrasto: sem isso, puxar o card seleciona o texto e o navegador começa a desenhar realce azul no meio do gesto. Se a bio precisar ser copiável, o componente errado está sendo usado.
O card é focável (tabindex="0") sem ser um link. Ele não navega; a bio é que abre. Isso é aceitável porque a bio já está no DOM em qualquer estado — o painel é tratamento visual, e quem usa leitor de tela lê a bio junto do nome, aberta ou fechada.
--pfl-title tem teto baixo para uma display normal. Mesma história do conteudo/apresentacao-foto-alternada: o original roda 160px numa fonte condensada. Suba o valor junto com a fonte, nunca sozinho.
O título display gigante virou primitiva. Eram dois componentes quando esta ficha foi escrita; o terceiro apareceu em 2026-09-01 e a regra foi promovida para .display-xl no core/base.css, como o §6 manda.
Origem. Escrito do zero a partir da observação da dobra "Os sócios" da página de investimentos de ronymeisler.com.br — site publicado em Framer, CSS gerado por máquina, classes hasheadas, sem licença declarada. Nada foi copiado: o que veio de lá foi a medida, lida no navegador (colunas de 468 + 80 + trilho sangrado; card de 485 × 600 com foto de 485 × 449; painel da bio em rgb(137,147,136); esteira de 150px a 10% de opacidade), e a ideia de juntar as três peças numa dobra. Status externo: nunca rodou em produção nossa. O primeiro site que usar promove — e a ficha registra qual foi. A referência está registrada em projetos/referencias-externas.md.
Normalizações aplicadas na tradução:
Cores cruas trocadas por token: fundo #f1f0eb → escopo .sec-paper; filete rgba(0,0,0,.2) → --rule; painel da bio rgb(137,147,136) (um sage) → --accent com texto em --dark. O sage era literalmente a cor de marca daquele projeto — aqui vira a cor de marca de quem usar.
Medidas fixas viraram variável: 468px → --pfl-col; 485px → --pfl-card; 65px de vão → --pfl-space; 150px de esteira → --pfl-strip-size.
Sangria refeita pela regra da casa (--pfl-inset no grid), sem margem negativa.
Entrada por data-rv/data-d/.ttl do core.
Acessibilidade que não existia: trilho focável com rótulo, setas de teclado, Enter/Espaço para abrir a bio, clones fora da ordem de tabulação, esteira com aria-hidden.
Divergências conhecidas vs. a referência:
O painel da bio cobre só a foto, não o card inteiro. No original ele cobre tudo e apaga o nome da pessoa justamente enquanto se lê a bio dela. Aqui nome e cargo continuam embaixo. O DOM do original ainda declara justify-content:space-between num painel com um filho só — resto de um desenho em que havia algo no rodapé do painel.
A bio entra com blur e subida, e não em fade puro (contrato §5) — mas quem sobe e desfoca é o texto, não o painel. Ver o gotcha sobre as frestas.
Os botões anterior/próximo do original não foram trazidos. Lá eles existem no DOM com 0×0 de tamanho — presentes e invisíveis. Aqui a navegação sem arrasto são as setas do teclado, que funcionam de verdade.
A esteira do rodapé é CSS puro; no original é um ticker em JavaScript, com transform reescrito a cada quadro. Uma animação de translateX até -50% faz o mesmo por menos.
O corpo do título é menor, pelo motivo do gotcha: a fonte da referência é condensada.