<!-- ==========================================================================
VÍDEO ESCOVADO PELO SCROLL — a dobra em que a rolagem é a agulha
Prefixo: esc-
Requer: core (tokens.css, base.css, motion.css, motion.js)
+ este estilo.css e este script.js
+ 1 vídeo curto COM KEYFRAME DENSO e o pôster dele (ver ficha,
"Especificação dos assets" — a codificação não é detalhe aqui,
é a diferença entre escovar e travar)
A IDEIA. A seção declara um trilho alto e o palco gruda nele
(position:sticky). Enquanto o palco está preso, a fração já rolada do
trilho vira o `currentTime` do vídeo: 0 no começo, a duração inteira no
fim. O vídeo NÃO TOCA — ele é escovado. Quem move a agulha é a pessoa,
e ela move para os dois lados: rolar de volta desfaz o quadro, o que um
vídeo tocando nunca faz.
POR QUE ISTO NÃO É UM VÍDEO DE FUNDO. O irmão hero/video-fundo põe um
loop tocando atrás da headline: o movimento é ambiente, acontece sem
ninguém e tem de poder ser parado (WCAG 2.2.2). Aqui o movimento é o
GESTO da pessoa, e é por isso que esta peça não tem botão de pausa —
ver a nota no fim deste comentário. Se a página quer textura, use o
irmão; se quer que a rolagem CONDUZA uma imagem, é esta.
GSAP NÃO ATRAVESSA. A referência faz isto com ScrollTrigger e scrub. A
conta é `getBoundingClientRect().top` do trilho dividido pelo curso, e
o laço é o `LIB.onScroll` do core — um rAF para a página inteira, como
manda o §7 do contrato. Nenhuma lib.
O <video> NÃO TRAZ <source>. A biblioteca não versiona vídeo, e o §11
não deixa asset de terceiro atravessar. O que está no ar neste preview
é o PÔSTER, parado — que é exatamente o fallback que o componente
promete em todos os caminhos de falha. No uso do consumidor,
descomente os dois <source> e aponte para os arquivos. Precedente do
mesmo recurso: hero/video-fundo e midia/showreel-expansivel.
OS TRÊS ATRIBUTOS DO <video> ANDAM JUNTOS, e a lista é diferente da do
irmão de fundo:
muted sem som não há o que a política de autoplay proteja, e é
o que permite o destravamento do iOS lá no script;
playsinline sem ele o iPhone abre o vídeo em TELA CHEIA por cima do
site no primeiro play();
preload=auto e este é o que muda em relação ao irmão. Escovar é pedir
quadros fora de ordem: `metadata` mandaria a agulha
procurar bytes que não chegaram, e o resultado é o
pôster piscando no meio da dobra. O preço é o arquivo
inteiro na rede, e é por isso que a ficha crava 4 a 8
segundos e peso pequeno — a peça se paga em duração,
não em compressão.
NÃO HÁ `autoplay` E NÃO HÁ `loop`: nada aqui anda sozinho.
O PÔSTER NÃO É ENFEITE. É o que a pessoa vê enquanto o arquivo chega, e
é o que ela vê PARA SEMPRE em quatro casos reais: movimento reduzido
ligado no sistema, economia de dados ligada no celular, JavaScript
ausente, e iOS que recusou o destravamento. Nos quatro a dobra continua
inteira e legível — é a mesma copy, sobre a mesma imagem, sem o
movimento. Por isso o pôster tem de ser um quadro do próprio vídeo.
A LEITURA DE PROGRESSO NASCE [hidden] e quem a mostra é o script. Sem
JavaScript ela seria um mostrador travado em 0%, que é pior que
mostrador nenhum. Sob movimento reduzido o script não a mostra de
propósito: não há escova, e um progresso sem o que ele mede é ruído.
O PERCENTUAL É DE ROLAGEM, E A RESSALVA É PARTE DA PEÇA. Num site de
assessoria, um número grande em porcentagem ao lado de um filme
institucional é lido como rendimento por quem passa o olho. O
`.esc-nota` diz, no mesmo bloco e sem letra miúda escondida, que aquilo
mede quanto desta dobra já foi rolado — e nada mais. Apagar a nota é
transformar uma barra de rolagem numa promessa; ela não é opcional.
NÃO TEM BOTÃO DE PAUSA, E ISSO É DECISÃO. A WCAG 2.2.2 exige um jeito
de parar o que anda SOZINHO por mais de cinco segundos — e aqui nada
anda sozinho: o vídeo só muda enquanto o dedo ou a roda movem a página,
e parar de rolar já é a pausa. Um botão de pausa que congelasse a
escova só tiraria da pessoa o controle que ela já tem. O caso em que
ele passa a ser OBRIGATÓRIO é nomeado: se alguém somar qualquer
movimento automático a esta dobra — o vídeo voltando a tocar sozinho no
fim do trilho, uma legenda que troca em relógio, um contador —, então
entra o `.btn-play` do core com o `LIB.pausavel`, como no
hero/slideshow-cinematico.
COMPOSIÇÃO, e ela é opcional de propósito: a copy por cima pode ser o
`efeitos/destaque-texto-scroll` em vez do `data-rv` que está aqui. É
variação documentada na ficha, não dependência — este markup não
carrega nenhum gancho daquele componente, e a peça fecha sozinha.
========================================================================== -->
<sectionclass="esc sec-deep"id="manifesto"data-esc>
<!-- O TRILHO é só altura: é ele que define quantas telas de rolagem valem
o vídeo inteiro (--esc-trilho). Não pode ganhar overflow diferente de
visible, nem aqui nem na <section>: overflow num ancestral prende o
sticky a um scrollport que não rola, e o palco deixa de grudar. Quem
recorta é o próprio palco. -->
<divclass="esc-trilho"data-esc-trilho>
<divclass="esc-palco"data-esc-palco>
<!-- A mídia SAI do .wrap para sangrar até a borda (§4): nada de
margem negativa nem 100vw, que inclui a barra de rolagem. -->
<divclass="esc-media">
<videoclass="esc-video"data-esc-videomutedplaysinlinepreload="auto"poster="../../../showroom/demo/ph-wide-2.webp"aria-hidden="true">
<!-- <source src="/midia/manifesto.webm" type="video/webm"> --><!-- <source src="/midia/manifesto.mp4" type="video/mp4"> -->
</video>
<divclass="esc-scrim"></div>
<divclass="grain"></div>
</div>
<!-- O véu é irmão da mídia, e não filho do bloco de texto: precisa
sangrar igual ao vídeo. Véu que parasse na coluna desenharia duas
linhas verticais duras no meio da imagem. A máscara em degradê é
a regra de forma do §6 — backdrop-filter em caixa reta produz
borda dura e lê "chapado". -->
<divclass="esc-veu"aria-hidden="true"></div>
<divclass="wrap esc-body">
<divclass="esc-copy">
<spanclass="eyebrow"data-rv>Manifesto</span>
<!-- .display-xl é a primitiva do core; o corpo e a entrelinha
entram por parafuso no ancestral (.esc-copy), como a própria
primitiva pede. .ttl é o reveal de título do core: não
escreva observer próprio. -->
<h2class="display-xl esc-title ttl">
Patrimônio não se conduz no <em>improviso</em>.
</h2>
</div>
<divclass="esc-pe">
<!-- data-rv num INVÓLUCRO, e o texto por dentro: o core termina o
reveal com [data-rv].in{transform:none}, que apagaria em
silêncio qualquer transform próprio no mesmo nó (§5). Aqui
não há transform próprio, mas a forma é a da casa e sobrevive
a quem vier mexer. -->
<divclass="esc-texto"data-rv>
<p>
Toda decisão de carteira nasce de um documento: a política de
investimentos da família, escrita antes e revisada em comitê.
</p>
<p>
Não distribuímos produto próprio e não recebemos comissão de
quem o fabrica. O honorário é acordado antes.
</p>
</div>
<!-- LEITURA DE PROGRESSO.
role="progressbar" e não região viva, e a escolha é medida
pelo comportamento do leitor de tela: este número muda a cada
quadro de rolagem, e um aria-live falaria por cima de tudo,
sem parar, enquanto a pessoa desce a página. O progressbar é
consultado QUANDO A PESSOA QUER — o aria-valuenow fica sempre
correto e ninguém é interrompido.
O aria-valuetext existe porque "42" sozinho não diz do quê: o
texto nomeia a unidade e o objeto.
.esc-marcas é aria-hidden por ser a duplicata VISUAL do que o
progressbar já reporta — sem ele a pessoa ouviria o mesmo
número duas vezes. A nota NÃO é escondida: a ressalva de
compliance vale para todo mundo. -->
<divclass="esc-leitura"data-esc-leiturahidden>
<divclass="esc-barra"data-esc-barrarole="progressbar"aria-label="Posição de leitura nesta dobra"aria-valuemin="0"aria-valuemax="100"aria-valuenow="0"aria-valuetext="0% desta dobra">
<spanclass="esc-barra-fill"></span>
</div>
<pclass="esc-marcas"aria-hidden="true">
<spanclass="esc-marca">Início</span>
<spanclass="esc-pct"data-esc-pct>0%</span>
<spanclass="esc-marca">Fim</span>
</p>
<pclass="nota esc-nota">
O percentual acima mede apenas quanto desta seção já foi rolado.
Não é rentabilidade, resultado nem indicador de carteira. Imagem
ilustrativa.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
estilo.css · 385 linhas · 17,1 KB
/* ==========================================================================
VÍDEO ESCOVADO PELO SCROLL · prefixo esc-
Um trilho alto, um palco que gruda nele e um <video> cujo currentTime
acompanha a fração rolada. O CSS não anima nada: quem escreve --esc-p
(0 a 1) e o currentTime é o script, dentro do laço único do core.
Tokens usados:
--dark-rgb scrim e véu sobre a mídia
--fg --fg-2 tinta do título e da prosa
--fg-muted marcas "Início"/"Fim" e a ressalva (via .nota)
--rule --rule-2 filete do pé e calha da barra de progresso
--accent preenchimento da barra
--accent-ink realce em itálico do título (NUNCA --accent cru: em
escopo claro o accent puro não fecha contraste, §3)
--display --sans faces
--fs-body corpo da prosa
--nav-h folga do menu fixo no topo do palco preso
--sec-gap respiro da seção no modo estático
--radius-pill ponta da barra
--ease curva única do projeto
Parafusos (custom properties do próprio componente, §3):
--esc-trilho QUANTAS TELAS o trilho mede, sem unidade. 3.2 no
padrão, com piso 2.2 e teto 4.8 obrigados pelo
clamp() — ver "a conta do trilho" abaixo
--esc-vh altura de uma tela (svh onde existe, vh no resto)
--esc-video-op opacidade do vídeo sob o scrim — o parafuso do
CONTRASTE
--esc-veu-alt altura do véu desfocado
--esc-veu-blur raio do desfoque do véu — o único custo por quadro
desta peça. Zerar mantém o degradê e devolve a GPU
--esc-barra-h espessura da barra de progresso
--esc-p 0 a 1, ESCRITO PELO SCRIPT. Não é para o consumidor
---------------------------------------------------------------------
A CONTA DO TRILHO, que é o que se ajusta quando a peça "corre demais".
O trilho mede --esc-trilho telas. O palco mede UMA. Logo o palco fica
preso por (--esc-trilho - 1) telas de rolagem, e é esse curso que vale a
duração inteira do vídeo. No padrão 3.2:
2,2 telas de rolagem = o vídeo inteiro
numa janela de 900px -> 1.980px de rolagem
num vídeo de 6s -> 330px de rolagem por segundo de filme
Um dedo dá uns 600px por gesto de rolagem em celular; uma roda de mouse
dá ~100px por clique. Ou seja: cada clique de roda avança 0,3s de filme,
que é a densidade em que o olho ainda lê movimento contínuo.
O PISO É 2.2 E O TETO É 4.8, e os dois são obrigados pelo clamp() de
propósito. Abaixo de 2.2 o vídeo inteiro passa em pouco mais de uma tela
de rolagem: um gesto de dedo pula segundos, o decodificador não alcança e
a peça vira estroboscópio. Acima de 4.8 a dobra vira armadilha de
rolagem — cinco telas sem conteúdo novo, e quem só queria chegar ao
rodapé fica preso girando a roda. Quem precisar sair da faixa troca o
clamp e assume a decisão por escrito na própria página.
---------------------------------------------------------------------
DOIS MODOS, E O PADRÃO É O SEGURO.
modo ESTÁTICO .esc sozinho — uma dobra comum: o pôster ocupa a caixa, a
copy por cima, sem sticky e sem trilho alto. É o que se vê
sem JavaScript, sob movimento reduzido e sob economia de
dados.
modo PRESO .esc.is-preso — o script mede, o palco gruda e a escova
acontece.
O preso é OPT-IN por classe, e não o contrário, porque ele depende de
JavaScript para fazer sentido: sem o script, uma seção de 3,2 telas com
um pôster parado seriam duas telas e meia de nada. Sendo o estático o
padrão, JS bloqueado, quebrado ou ainda não executado entrega uma dobra
íntegra — não um vão.
Por isso também NÃO HÁ @media(scripting:none) neste arquivo: o estado de
repouso já é o correto, e não há portão de classe a destravar. O único
bloco que nasce invisível é a leitura de progresso, e ela nasce [hidden]
no markup — sem script ela continua escondida, que é o certo.
A troca entre os dois modos é toda por VARIÁVEL (--esc-pos, --esc-h,
--esc-of…), não por regra duplicada: cada modo mora num lugar só. Mesma
forma do midia/faixa-horizontal-pinada.
---------------------------------------------------------------------
O QUE NÃO PODE GANHAR overflow: a <section> e o .esc-trilho. Overflow
diferente de visible num ancestral do sticky prende o palco a um
scrollport que não rola, e ele deixa de grudar — sem erro, sem aviso, só
um palco que desce junto com a página. Quem recorta é o .esc-palco, que é
o próprio elemento que gruda.
OS NÚMEROS DE CONTRASTE SÃO HERDADOS, e é honesto dizer de onde: a
receita de scrim vertical + véu desfocado com máscara é a mesma do irmão
hero/video-fundo, e --esc-video-op repete os valores que ELE mediu contra
um quadro de vídeo branco chapado (.46 no desktop, .38 no celular, para
4,5:1 de piso em todo texto). O que muda aqui é a favor da leitura: a
mancha de texto é menor e o véu sobe mais alto, então o piso medido lá é
conservador aqui. Quem trocar o vídeo por um material claro de verdade
refaz a medida — a tabela do irmão está no estilo.css dele.
========================================================================== */.esc{
/* ---- parafusos ---- */--esc-vh:100vh;
--esc-trilho:3.2;
--esc-video-op:.46;
--esc-veu-alt:66%;
--esc-veu-blur:10px;
--esc-barra-h:2px;
/* ---- modo ESTÁTICO (padrão) ---- */--esc-pos:static;
--esc-h:auto;
--esc-min:clamp(520px,calc(var(--esc-vh) * .78),880px);
--esc-of:visible;
--esc-rail:auto;
--esc-pad:var(--sec-gap);
--esc-top:clamp(24px,4vh,48px);
position:relative;
}
/* svh desconta a barra do navegador no celular, onde vh a ignora e o pé do
palco fica embaixo dela. No sticky isso é pior que estética: com vh o
valor muda no meio do gesto e o palco salta. svh é a MENOR das alturas e
não se mexe; dvh seria pior ainda, muda a cada quadro. Em @supports
porque a peça tem de abrir inteira em base antiga, e ali vh é o que há. */@supports (height:100svh){.esc{--esc-vh:100svh}}
/* ---- modo PRESO ----
Ligado pelo script depois de ele confirmar que ninguém pediu menos
movimento e que a conexão não está em economia de dados. */.esc.is-preso{
--esc-pos:sticky;
--esc-h:var(--esc-vh);
--esc-min:0px;
--esc-of:hidden;
--esc-rail:calc(var(--esc-vh) * clamp(2.2,var(--esc-trilho),4.8));
--esc-pad:0px;
/* A folga do menu fixo só existe no modo preso, e é por isso que ela é
variável: no estático o palco passa por baixo do menu como qualquer
outra dobra, e reservar --nav-h ali seria um vão de 84px no meio da
página. */--esc-top:calc(var(--nav-h) + clamp(32px,6vh,72px));
}
/* Cinto e suspensório do §6.6, colado à regra que desliga: o script já não
põe .is-preso sob movimento reduzido, mas se alguém colar a classe à mão
no markup — ou um script futuro a puser antes de consultar a preferência
— o modo preso morre aqui. Prender a rolagem da página a uma agulha de
vídeo é exatamente o movimento imposto que a preferência pede para
evitar. */@media(prefers-reduced-motion:reduce){
.esc.is-preso{
--esc-pos:static;
--esc-h:auto;
--esc-min:clamp(520px,calc(var(--esc-vh) * .78),880px);
--esc-of:visible;
--esc-rail:auto;
--esc-pad:var(--sec-gap);
--esc-top:clamp(24px,4vh,48px);
}
}
.esc-trilho{height:var(--esc-rail)}
.esc-palco{
position:var(--esc-pos);top:0;
height:var(--esc-h);
min-height:var(--esc-min);
overflow:var(--esc-of);
padding-block:var(--esc-pad);
display:flex;flex-direction:column;
}
/* ---- mídia ----------------------------------------------------------------
z-index:0 de propósito: cria o contexto de empilhamento que prende o
.grain (que traz z-index:1 do core) aqui dentro, e deixa véu e corpo
ordenados sem ninguém adivinhar número.
inset:0 nos DOIS modos, e é o que faz a geometria ser uma só: no preso a
mídia enche a tela grudada; no estático ela enche a caixa de --esc-min,
inclusive por baixo do padding. Um único desenho, dois tamanhos. */.esc-media{position:absolute;inset:0;z-index:0;overflow:hidden}
/* object-fit vale para o vídeo E para o pôster — e aqui o pôster é o que
sustenta a dobra em quatro caminhos de falha (sem script, movimento
reduzido, economia de dados, iOS que recusou o destravamento). Sem ele o
pôster de 16:9 entra com tarja dos dois lados em tela mais alta que
larga. */.esc-video{
position:absolute;inset:0;
width:100%;height:100%;
object-fit:cover;object-position:50%45%;
opacity:var(--esc-video-op);
}
/* Dois gradientes cruzados, com o VERTICAL como principal: o texto atravessa
a coluna inteira ancorado embaixo, e o que precisa escurecer é o pé — mais
o topo, por causa do menu fixo, que neste componente encosta no palco toda
vez que ele está preso. O horizontal entra fraco, para o canto direito não
brilhar sozinho em tela larga por trás da leitura de progresso. */.esc-scrim{
position:absolute;inset:0;
background:
linear-gradient(to bottom,
rgba(var(--dark-rgb),.62) 0%,
rgba(var(--dark-rgb),.20) 28%,
rgba(var(--dark-rgb),.34) 56%,
rgba(var(--dark-rgb),.66) 100%),
linear-gradient(94deg,
rgba(var(--dark-rgb),.34) 0%,
rgba(var(--dark-rgb),.08) 52%,
rgba(var(--dark-rgb),.30) 100%);
}
/* ---- véu desfocado -------------------------------------------------------
A regra de forma do §6: backdrop-filter SEMPRE com máscara em degradê.
Caixa reta desenharia uma linha horizontal dura no meio do vídeo e leria
"chapado".
POR QUE ESTA PEÇA PEDE MAIS VÉU QUE A DE FOTO: com foto o autor escolhe o
quadro e calibra o scrim contra ele. Aqui TODO quadro do vídeo passa por
baixo da mesma copy, e passa porque a pessoa está rolando — o clarão do
segundo cinco chega debaixo do título sem aviso. Gradiente escurece a
média; o desfoque apaga o DETALHE, que é o que come a leitura de texto
claro sobre imagem em movimento.
É também o único custo por quadro do CSS desta peça: o desfoque é
recalculado enquanto a imagem por baixo muda — e aqui ela muda a cada
escova. Em máquina fraca, --esc-veu-blur:0 mantém o degradê e devolve a
GPU. */.esc-veu{
position:absolute;left:0;right:0;bottom:0;z-index:1;
height:var(--esc-veu-alt);pointer-events:none;
background:linear-gradient(to top,
rgba(var(--dark-rgb),.66) 0%,
rgba(var(--dark-rgb),.38) 52%,
rgba(var(--dark-rgb),0) 100%);
-webkit-mask-image:linear-gradient(to top,#0000%,rgba(0,0,0,.9) 48%,transparent 100%);
mask-image:linear-gradient(to top,#0000%,rgba(0,0,0,.9) 48%,transparent 100%);
}
/* Mesma cautela do .glass no core: sem suporte sobra só o degradê, em vez de
uma faixa translúcida e sem função. */@supports (backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px)){
.esc-veu{
backdrop-filter:blur(var(--esc-veu-blur)) saturate(112%);
-webkit-backdrop-filter:blur(var(--esc-veu-blur)) saturate(112%);
}
}
/* ---- corpo ---------------------------------------------------------------
Ancorado embaixo: o topo fica para a mídia respirar sob o menu fixo, e o
texto vive DENTRO da imagem, como manda o §6 — nunca num parágrafo abaixo
dela.
O padding vertical é local e legítimo: o §4 proíbe o componente de mexer
em padding-inline, max-width de página e margem lateral, e é o .wrap desta
mesma <div> que continua respondendo sozinho pela coluna. */.esc-body{
position:relative;z-index:2;
flex:11 auto;
display:flex;flex-direction:column;justify-content:flex-end;
padding-top:var(--esc-top);
padding-bottom:clamp(40px,7vh,88px);
}
/* Os dois parafusos da primitiva .display-xl entram AQUI, no ancestral, e
não na regra do título: o core os lê como fallback dentro do var(), e um
valor escrito no mesmo elemento venceria o que o ancestral põe. Mesma
forma do conteudo/pilares-texto-na-foto.
O corpo é menor que o padrão da primitiva (clamp(46px,6vw,104px)) porque
aqui o título divide UMA TELA com a prosa e a leitura de progresso — e a
tela é fixa, o palco está preso. Em display gigante a dobra não fechava
em notebook de 900px. */.esc-copy{
--display-xl:clamp(32px,4.6vw,68px);
--display-xl-lh:1.05;
}
/* A medida vai no TÍTULO e não no .esc-copy, e isto foi medido: `ch` resolve
contra a fonte do PRÓPRIO elemento, e o .esc-copy herda o corpo de 17px do
body — 20ch ali deram 210px de coluna e cinco linhas de título em
1440x900, quando a intenção eram duas. No .esc-title o mesmo `ch` vale a
letra de 66px. Mesma forma do h2.sec-title do core, que leva o max-width
de 22ch no próprio h2. */.esc-title{max-width:15ch;margin-top:18px}
/* Elemento descendente do próprio prefixo, no precedente do `.btn svg` e do
`.field label` do core: preso à classe, não vaza. Lê a TINTA DE REALCE do
escopo, e não o --accent cru — em escopo claro o accent puro mede 1.36:1
com o sage da Liberta, contra um mínimo de 3:1 (§3). O fallback mantém de
pé quem colar a peça fora de um escopo de tom. */.esc-title em{
font-style:italic;font-weight:700;letter-spacing:-.01em;
color:var(--accent-ink,var(--accent));
}
/* ---- pé: prosa à esquerda, leitura à direita ---------------------------- */.esc-pe{
display:flex;align-items:flex-end;justify-content:space-between;
gap:clamp(28px,4vw,72px);
margin-top:clamp(30px,5vh,58px);
border-top:1px solid var(--rule);
padding-top:22px;
}
.esc-texto{max-width:52ch}
/* Prosa que o autor do texto escreve livre: `p` como descendente do próprio
prefixo é o caso PERMITIDO pelo §6.3 — fora daí, classe. */.esc-texto p{
font-size:var(--fs-body);line-height:1.6;color:var(--fg-2);
}
.esc-texto p + p{margin-top:14px}
/* ---- leitura de progresso ------------------------------------------------
Nasce [hidden] no markup e quem a mostra é o script: sem JavaScript ela
seria um mostrador travado em 0%. A primeira regra é a do §6.5 — quem dá
`display` a algo que às vezes é [hidden] desliga à mão, porque QUALQUER
regra de autor com display vence o [hidden] do navegador. Aqui a
armadilha chegaria pela porta de trás do flex: um item de flex container
continua obedecendo display:none, mas basta alguém somar um display nesta
classe para o mostrador nascer visível e morto, sem nada acusar. */.esc-leitura[hidden]{display:none}
.esc-leitura{flex:00 auto;width:min(34ch,100%)}
.esc-barra{
position:relative;overflow:hidden;
height:var(--esc-barra-h);
background:var(--rule-2);
border-radius:var(--radius-pill);
}
/* Cresce por transform:scaleX e não por width: width remonta a caixa a cada
quadro, e este valor muda em todo quadro de rolagem. Mesma decisão do
navegacao/progresso-leitura. Não há transition: o valor já vem do scroll,
que é contínuo — suavizar por cima só atrasaria a barra em relação à
imagem que ela mede. */.esc-barra-fill{
position:absolute;inset:0;
transform-origin:050%;
transform:scaleX(var(--esc-p,0));
background:var(--accent);
}
.esc-marcas{
display:flex;align-items:baseline;justify-content:space-between;gap:12px;
margin-top:11px;
font-family:var(--display);font-weight:700;
font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;
color:var(--fg-muted);
}
.esc-pct{
font-size:15px;letter-spacing:.02em;text-transform:none;
color:var(--fg);font-variant-numeric:tabular-nums;
}
/* .nota do core traz corpo, entrelinha e tinta; margem, medida e alinhamento
continuam locais, como a primitiva manda. --nota-max entra porque 74ch num
canto de 34ch não teria efeito nenhum e esconderia a intenção. */.esc-nota{--nota-max:34ch;margin-top:13px}
/* ---- mobile --------------------------------------------------------------
O trilho encurta e o palco verticaliza. O modo preso CONTINUA no celular,
ao contrário do midia/faixa-horizontal-pinada: lá o modo nativo é uma
faixa que o dedo arrasta, e existe; aqui o "modo nativo" seria um pôster
parado, ou seja, o componente deixaria de existir na metade do tráfego.
Os números fecham a dobra dentro de 844px de janela:
folga do topo = menu + 22px (era menu + 54) -32px
respiro do pé 40px -20px
prosa em 14.5px e o pé em coluna o resto
--esc-trilho cai de 3.2 para 2.6 porque o gesto de dedo é longo: 2,2 telas
de curso num celular são quatro ou cinco arremessos, e o filme de seis
segundos nunca chegava ao fim antes de a pessoa desistir. */@media(max-width:760px){
.esc{--esc-trilho:2.6;--esc-video-op:.38;--esc-veu-alt:80%;--esc-veu-blur:8px}
.esc.is-preso{--esc-top:calc(var(--nav-h) + 22px)}
.esc-body{padding-bottom:40px}
.esc-copy{max-width:100%}
.esc-pe{
display:block;
margin-top:26px;padding-top:18px;
}
.esc-texto{max-width:100%}
.esc-texto p{font-size:14.5px}
.esc-leitura{width:100%;margin-top:22px}
.esc-nota{--nota-max:none;--nota-size:11.5px}
}
script.js · 294 linhas · 14,1 KB
/* ==========================================================================
VÍDEO ESCOVADO PELO SCROLL · prefixo esc-
O QUE ESTE SCRIPT FAZ, E SÓ ISTO:
1. decide se a peça PODE escovar (movimento reduzido e economia de dados
são os dois vetos) e, podendo, põe .is-preso na raiz — é essa classe
que liga o trilho alto e o sticky no CSS;
2. mostra a leitura de progresso, que nasce [hidden] porque sem script
seria um mostrador travado em 0%;
3. a cada quadro do laço do core, lê a fração já rolada do trilho e
escreve --esc-p (a barra) e o aria-valuenow (o leitor de tela);
4. traduz essa fração em `currentTime`, e só escreve no vídeo quando o
alvo andou mais que o limiar.
NÃO HÁ LIB. A referência faz isto com GSAP/ScrollTrigger e `scrub`. A
conta inteira é getBoundingClientRect().top do trilho dividida pelo curso,
e o laço é o LIB.onScroll do core — um rAF para a página inteira, como
manda o §7. Nenhuma dependência atravessou.
---------------------------------------------------------------------
O LIMIAR, que é a única linha desta peça que separa "escova" de "engasga".
Escrever `currentTime` é PEDIR UM SEEK ao decodificador, não mover um
ponteiro. Um seek por quadro de rolagem — 60 por segundo, muitos deles
pedindo o mesmo quadro — enfileira trabalho que o decodificador não
consegue descartar, e o sintoma é o vídeo travando alguns décimos atrás
do dedo e depois pulando para alcançar.
LIMIAR = 0,025s é MENOS que um quadro a 30fps (0,0333s) e menos que um
quadro a 25fps (0,04s): ou seja, nenhum quadro que exista no arquivo é
pulado por causa dele. O que ele corta é só o pedido redundante — o seek
para um quadro que já está na tela. Abaixo disso não há ganho de imagem, e
acima de ~0,04s a escova começa a andar em degraus visíveis.
MEDIDO, com um <video> de 6s e o trilho padrão em 1440x900 (curso de
1.980px, 330px de rolagem por segundo de filme): dez micro-rolagens de 2px
seguidas — 20px, ou 0,06s de filme — produziram DOIS seeks, e não dez. Nas
três frações conferidas o currentTime bateu exato com a conta:
0,2 -> 1,190s · 0,5 -> 2,975s · 0,8 -> 4,760s, contra 5,95s de mira final.
MIRAR EM `duration`, NUNCA. O último quadro pode simplesmente não existir
no arquivo — a duração declarada no contêiner costuma passar alguns
milissegundos do último quadro de vídeo, e um seek para lá devolve o
elemento ao estado 'ended', que em alguns motores REPINTA O PÔSTER. A
dobra terminaria com um salto para a foto. Por isso a mira é
`duration - MARGEM_FIM`, com MARGEM_FIM de 0,05s.
---------------------------------------------------------------------
iOS: O VÍDEO SÓ ACEITA SER ESCOVADO DEPOIS DE UM play()/pause().
No Safari de iPhone um <video> que nunca tocou não tem pipeline de
decodificação montado, e escrever `currentTime` nele não repinta nada: o
elemento fica no pôster e o console não diz uma palavra. O destravamento é
um play() seguido de pause() — e play() é gesto-dependente, então há duas
tentativas:
1. assim que os metadados chegam. Com `muted` a política costuma
permitir, e aí não custa nada à pessoa;
2. se aquela foi recusada, no PRIMEIRO pointerdown dentro da seção, que
é um gesto de verdade e quase sempre passa.
Se as duas falharem, não há terceira e não há erro: a escova segue sendo
pedida, o Safari segue ignorando, e o que fica na tela é o PÔSTER PARADO
com a copy inteira legível por cima. É um caminho de falha desenhado, não
um acidente — a mesma dobra, sem o movimento.
---------------------------------------------------------------------
NÃO HÁ BOTÃO DE PAUSA, E ISSO É DECISÃO. A WCAG 2.2.2 exige um jeito de
parar o que anda SOZINHO por mais de cinco segundos. Aqui nada anda
sozinho: não há `autoplay`, não há `loop`, não há relógio. O vídeo só se
move enquanto a pessoa move a página, e parar de rolar já é a pausa — um
botão de pausa só tiraria dela o controle que ela já tem. O caso em que
ele passaria a ser OBRIGATÓRIO está nomeado na ficha: qualquer movimento
automático somado a esta dobra (o vídeo voltando a tocar no fim do trilho,
uma legenda em rodízio) traz de volta o .btn-play do core com o
LIB.pausavel.
MOVIMENTO REDUZIDO: não escova, ponto. Prender a rolagem da página a uma
agulha de vídeo é exatamente o movimento imposto que a preferência pede
para evitar. Sem .is-preso a seção é uma dobra comum com o pôster, a copy
inteira legível, e a leitura de progresso escondida — porque um progresso
sem o que ele mede é ruído.
========================================================================== */
(function () {
var raizes = document.querySelectorAll('[data-esc]');
if (!raizes.length) return; // guard silencioso/* Segundos. Ver a nota longa no cabeçalho — é menos que um quadro a 30fps,
então corta pedido redundante e nunca quadro. */var LIMIAR = 0.025;
/* Segundos de folga para não mirar no fim cravado da duração declarada. */var MARGEM_FIM = 0.05;
/* 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;
}
/* navigator.connection.saveData é a pessoa dizendo ao sistema que não quer
baixar megabyte, e esta peça pede o arquivo INTEIRO (preload="auto") —
é a primeira que devia obedecer. A API não existe no Safari nem no
Firefox, e a ausência dela cai no comportamento padrão (escovar), nunca
em erro. A linha de corte para no 2G pelo mesmo motivo do
hero/video-fundo: "3g" é onde boa parte do acesso móvel brasileiro se
reporta, e cortar ali removeria a peça do público para quem ela foi
desenhada. */function poupandoDados() {
var c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (!c) returnfalse;
if (c.saveData) returntrue;
return /(^|-)2g$/.test(c.effectiveType || '');
}
/* Preso ao scroll da PÁGINA: assina o laço único do core. O fallback
repete a semântica dele — chamada imediata e um rAF por quadro, nunca
conta dentro do listener. */function assinar(fn) {
if (window.LIB && window.LIB.onScroll) return window.LIB.onScroll(fn);
var pedido = 0;
function agendar() {
if (pedido) return;
pedido = requestAnimationFrame(function () { pedido = 0; fn(); });
}
addEventListener('scroll', agendar, { passive: true });
addEventListener('resize', agendar, { passive: true });
fn();
}
/* Number.isFinite é ES6 e o §1 pede ES5-safe. O isFinite global só difere
dele ao coagir string — e `duration` é sempre number (NaN antes dos
metadados, Infinity em transmissão ao vivo). A pergunta extra por > 0
descarta o arquivo de duração zero, que existe em mp4 truncado. */function duracaoUtil(v) {
return !!v && isFinite(v.duration) && v.duration > 0;
}
function iniciar(raiz) {
var trilho = raiz.querySelector('[data-esc-trilho]');
var palco = raiz.querySelector('[data-esc-palco]');
var video = raiz.querySelector('[data-esc-video]');
var leitura = raiz.querySelector('[data-esc-leitura]');
var barra = raiz.querySelector('[data-esc-barra]');
var pct = raiz.querySelector('[data-esc-pct]');
if (!trilho || !palco) return; // guard/* Os dois vetos. Sem .is-preso o CSS entrega a dobra estática, com o
pôster e a copy — e a leitura de progresso fica [hidden] como nasceu.
Com economia de dados ainda se cancela o preload declarado no markup,
que é o byte que a pessoa pediu para não gastar. */if (reduzido() || poupandoDados()) {
if (video) { try { video.preload = 'none'; } catch (e) {} }
return;
}
raiz.classList.add('is-preso');
if (leitura) leitura.hidden = false;
var fim = 0; /* duração menos a margem — o alvo máximo */var pronto = false; /* metadados chegaram e a duração é útil */var liberado = false; /* o play()/pause() do iOS já passou */var escrito = -1; /* último currentTime pedido */var pctEscrito = -1; /* último inteiro escrito na leitura */var perto = true; /* a dobra está por perto? *//* ------------------------------------------------------------ fração *//* O curso é o trecho de rolagem em que o palco fica PRESO: a altura do
trilho menos a altura do palco. Não é a altura da seção nem a do
trilho sozinha — usar qualquer uma delas faria o vídeo terminar antes
ou depois de o palco soltar, e o salto apareceria justo na emenda.
offsetHeight e não getBoundingClientRect().height: as duas caixas não
carregam transform nenhum, e offsetHeight não força o mesmo trabalho
de layout a cada leitura. */function fracao() {
var curso = trilho.offsetHeight - palco.offsetHeight;
if (curso <= 0) return0;
var p = -trilho.getBoundingClientRect().top / curso;
return p < 0 ? 0 : p > 1 ? 1 : p;
}
/* ------------------------------------------------------------ leitura *//* role="progressbar" e não região viva, e a escolha é sobre o que o
leitor de tela FAZ com cada um: este número muda a cada quadro de
rolagem, e um aria-live falaria por cima de tudo sem parar enquanto a
pessoa desce a página — o pior tipo de anúncio, o que impede de ouvir
o resto. O progressbar é consultado quando a pessoa quer, e o
aria-valuenow fica sempre correto.
Só escreve quando o INTEIRO muda: escrever o mesmo "42" sessenta vezes
por segundo é trabalho de árvore de acessibilidade sem nenhum ganho. */function leitor(p) {
raiz.style.setProperty('--esc-p', p.toFixed(4));
var n = Math.round(p * 100);
if (n === pctEscrito) return;
pctEscrito = n;
if (pct) pct.textContent = n + '%';
if (barra) {
barra.setAttribute('aria-valuenow', n);
/* "42" sozinho não diz do quê. O texto nomeia a unidade e o objeto,
e é a mesma frase da ressalva visível logo abaixo. */
barra.setAttribute('aria-valuetext', n + '% desta dobra');
}
}
/* ------------------------------------------------------------ escova */function escovar(p) {
if (!pronto) return; // antes disso, o pôstervar t = p * fim;
/* O LIMIAR. Ver o cabeçalho: escrever a cada quadro briga com o
decodificador e engasga. As duas pontas são exceção — em 0 e em 1 a
posição tem de ser exata, senão a dobra abre e fecha com o quadro
errado congelado. */if (p > 0 && p < 1 && Math.abs(t - escrito) < LIMIAR) return;
escrito = t;
/* currentTime em elemento sem mídia carregada lança em motores
antigos. Aqui não deveria acontecer (só se chega com `pronto`), mas
uma exceção num laço de scroll derrubaria a rolagem da página
inteira, e não só esta dobra. */try { video.currentTime = t; } catch (e) {}
}
/* ------------------------------------------------------- destravamento *//* play() seguido de pause(): é o que monta o pipeline de decodificação
no Safari de iPhone. Com `muted` a política costuma permitir sem
gesto; quando não permite, a promessa REJEITA — e rejeição não tratada
vira erro vermelho no console de quem só rolou a página. */function liberar() {
if (liberado || !video) return;
var p;
try { p = video.play(); } catch (e) { return; }
if (p && p['catch']) {
p.then(function () { video.pause(); liberado = true; })
['catch'](function () { /* fica o pôster; a segunda tentativa é o gesto */ });
} else {
video.pause();
liberado = true;
}
}
/* ------------------------------------------------------------ metadados */function metadados() {
if (!duracaoUtil(video)) return;
fim = video.duration - MARGEM_FIM;
if (fim < 0) fim = 0;
pronto = true;
liberar();
escovar(fracao());
}
if (video) {
/* Os metadados podem já ter chegado antes de este script rodar (cache,
preload="auto", script no fim do <body>): o evento não dispararia de
novo, e a peça ficaria no pôster para sempre. */if (duracaoUtil(video)) metadados();
else video.addEventListener('loadedmetadata', metadados);
/* Segunda tentativa do destravamento do iOS, num gesto de verdade.
once + passive: não cancela nada, não repete e some sozinha. */
raiz.addEventListener('pointerdown', function () {
if (!liberado && pronto) liberar();
}, { passive: true, once: true });
/* Arquivo que não carrega — caminho errado, 404, codec recusado — é o
erro mais provável que um consumidor comete. Não há o que "avisar"
na tela: o pôster já está lá e a copy já está legível. O que se faz é
parar de pedir seek para um elemento que não vai responder. */
video.addEventListener('error', function () { pronto = false; });
}
/* ----------------------------------------------------------- visibilidade *//* Fora da tela não há fração a calcular nem quadro a pedir. O
rootMargin generoso deixa a peça já medida quando ela entra. */if (window.IntersectionObserver) {
new IntersectionObserver(function (es) {
for (var i = 0; i < es.length; i++) perto = es[i].isIntersecting;
}, { rootMargin: '300px 0px' }).observe(raiz);
}
/* ------------------------------------------------------------ o quadro */
assinar(function () {
if (!perto) return;
var p = fracao();
leitor(p);
escovar(p);
});
}
for (var i = 0; i < raizes.length; i++) iniciar(raizes[i]);
})();
Uma seção alta prende a tela: o palco gruda (position:sticky) dentro de um trilho de altura declarada, e a fração já rolada desse trilho vira o currentTime do vídeo. O filme não toca — ele é escovado. Rolar para baixo avança; rolar de volta desfaz, quadro a quadro, o que nenhum vídeo tocando faz. Por cima, a copy do manifesto ancorada embaixo; num canto, a leitura de progresso com início, percentual e fim.
O que ela resolve editorialmente: dar tempo a uma frase de princípio. Um manifesto num bloco de texto comum passa em dois segundos de rolagem; aqui a mesma frase divide a tela com uma imagem que se move sob comando de quem lê, e o tempo de leitura vira decisão da pessoa.
Compliance
O percentual é de rolagem, e é isso que a ressalva existe para dizer.
Num site de assessoria, um número grande em porcentagem ao lado de um filme institucional é lido como rendimento por quem passa o olho — não porque a pessoa seja desatenta, mas porque é o que aquele formato significa no resto da página. Por isso:
a .esc-nota mora no mesmo bloco do número, não num rodapé de seção, e nega explicitamente as três leituras erradas: rentabilidade, resultado, indicador de carteira;
ela não é opcional. Quem quiser a dobra sem a ressalva tem de apagar o .esc-leitura inteiro — número sem ressalva não é uma versão desta peça;
o aria-valuetext repete a mesma unidade ("42% desta dobra"), para que a leitura por voz não fique mais ambígua que a visual.
A copy do manifesto não cita rentabilidade. Ela fala de método (política de investimentos escrita), de governança (comitê) e de remuneração (honorário acordado, sem comissão de produto). Números de resultado não entram nesta dobra — para número apurado existe o dados/grade-indicadores, que carrega fonte e data.
Quando usar
Manifesto, princípio da casa, "como trabalhamos" — texto curto que merece uma tela inteira e um tempo próprio.
Depois do hero ou no meio da página, quando a proposta já foi apresentada e o que falta é tom.
Quando existe filme de verdade: material de arquivo, imagem de escritório, mar, cidade, mãos sobre papel. Movimento contínuo e lento, sem corte.
Quando a página aguenta perder 2,2 telas de rolagem para uma dobra só.
Quando não usar
Sem vídeo. Sem o arquivo, a peça é um pôster parado com copy por cima — e isso é o conteudo/pilares-texto-na-foto, com três telas de rolagem a mais.
Com filme que tem corte, legenda ou fala. Escova não respeita corte: a pessoa pode parar exatamente no quadro de transição e ficar lá. E o vídeo é mudo por construção.
Duas vezes na mesma página. Ver "Padrão editorial".
Em página curta (uma landing de três dobras): a peça sozinha seria metade do site.
O irmão mais próximo, e quando ele ganha
midia/video-escovado-scroll
hero/video-fundo
Quem move o vídeo
a pessoa, rolando
o navegador, em loop
Posição
depois do hero, meio da página
primeira dobra
Botão de pausa
não tem, e é decisão (ver Gotchas)
tem, e a WCAG 2.2.2 o exige
preload
auto — escovar exige buffer
metadata — o pôster é o LCP
Custo de rolagem
2,2 telas
nenhum
O irmão ganha sempre que o vídeo for textura e não conteúdo, e ganha sempre que a dobra for a primeira: acima da linha de corte ninguém rolou ainda, então não há agulha. Um terceiro vizinho é o midia/faixa-horizontal-pinada, que usa a mesma mecânica de trilho + sticky com imagens paradas em vez de filme — ele ganha quando o conteúdo é uma fila de coisas e não um plano contínuo.
Padrão editorial
Slot
Regra
Olho
1 a 2 palavras. Opcional
Título do manifesto
4 a 9 palavras, com uma em <em>. Frase de princípio, não de oferta
Prosa
2 parágrafos, 2 a 4 frases no total. Frase curta, sem subordinada — o texto é lido sobre imagem em movimento
Marcas da régua
"Início" e "Fim", uma palavra cada, fixas
Ressalva
12 a 30 palavras. Obrigatória (ver Compliance)
Uma por página, e o limite é de rede antes de ser de desenho:preload="auto" baixa o arquivo inteiro, e duas dobras destas somam dois vídeos antes de a pessoa chegar à segunda.
O título não repete o olho e não repete o título da dobra anterior. Ele é a única linha em corpo grande da tela, e divide essa tela com a prosa e o progresso: acima de nove palavras passa de três linhas no celular e empurra a leitura de progresso para fora da dobra.
Especificação dos assets
Asset
Especificação
Vídeo
mp4/H.264 1920×1080, 4 a 8 segundos, até 2,5 MB. Um webm/VP9 adicional é bem-vindo
Codificação
keyframe em todo quadro — ffmpeg -g 1 -keyint_min 1 -sc_threshold 0 (ou "every-frame keyframes" no encoder do editor)
Pôster
1600×900 ou maior, um quadro do próprio vídeo, de preferência não o primeiro
Som
nenhum. O vídeo é muted e nunca toca — trilha e fala se perdem inteiras
Conteúdo
movimento contínuo e lento, sem corte, sem texto queimado na imagem, sem logo e sem rosto identificável (§11)
O keyframe denso não é otimização, é requisito de funcionamento. Num arquivo com GOP normal (2 a 10 segundos entre keyframes), um seek para um instante qualquer obriga o decodificador a voltar ao keyframe anterior e decodificar tudo até lá — e como a escova pede um instante novo a cada quadro de rolagem, ele nunca alcança. O sintoma é a imagem andando em degraus de meio segundo e travando. Com -g 1 todo quadro é keyframe: o seek é direto, e o preço é o arquivo pesar mais — que é exatamente por que a duração está cravada em 4 a 8 segundos e não em 30.
A conta da duração contra o trilho: o palco fica preso por (--esc-trilho - 1) telas. No padrão 3.2, são 2,2 telas de rolagem para o filme inteiro — numa janela de 900px, 1.980px de rolagem. Para um vídeo de 6s isso dá 330px de rolagem por segundo de filme, ou 0,3s de filme por clique de roda de mouse. Filme mais longo pede trilho mais longo, e o teto do trilho é 4,8 telas: acima de ~8 segundos a dobra vira armadilha de rolagem.
Cole a <section>, troque o poster, descomente os dois <source> e aponte para os arquivos. A biblioteca não versiona vídeo (§11), então o preview mostra o pôster parado — que é, e não por acaso, o mesmo fallback que a peça entrega em produção quando algo dá errado.
Não ponha overflow na <section> nem no .esc-trilho. Ver Gotchas.
Ganchos
Gancho
Papel
[data-esc]
a seção, raiz do script
[data-esc-trilho]
o bloco de altura declarada onde o palco gruda
[data-esc-palco]
o position:sticky, e o único elemento que recorta
[data-esc-video]
o <video> a escovar
[data-esc-leitura]
o bloco de progresso. Nasce [hidden]; quem o mostra é o script
[data-esc-barra]
o role="progressbar" — o script escreve aria-valuenow e aria-valuetext
[data-esc-pct]
o número em % visível
.esc.is-preso
escrita pelo script: liga o trilho alto e o sticky
--esc-p
0 a 1, escrita pelo script a cada quadro. Não é parafuso de consumidor
Parafusos
Variável
Padrão
O quê
--esc-trilho
3.2 (2.6 no celular)
quantas telas o trilho mede. Piso 2,2 e teto 4,8 obrigados pelo clamp()
--esc-vh
100svh
altura de uma tela. vh onde svh não existe
--esc-video-op
.46 (.38 no celular)
opacidade do vídeo sob o scrim — o parafuso do contraste
--esc-veu-alt
66% (80% no celular)
altura do véu desfocado
--esc-veu-blur
10px
raio do desfoque. Zerar devolve a GPU e mantém o degradê
--esc-barra-h
2px
espessura da barra
--display-xl
clamp(32px,4.6vw,68px)
corpo do título, escrito no .esc-copy (a primitiva o lê do ancestral)
--nota-max
34ch
medida da ressalva
Variações
Trilho mais longo ou mais curto — --esc-trilho. Dentro da faixa 2,2–4,8, que o clamp() obriga.
No tom claro — trocar .sec-deep por .sec-paper. Nenhuma linha de CSS muda: o realce do título lê --accent-ink e o scrim lê --dark-rgb, que continua sendo a tinta certa para escurecer vídeo em qualquer tom.
Sem a leitura de progresso — apagar o .esc-leitura inteiro. Vale quando a página já tem o navegacao/progresso-leitura no topo: dois indicadores de rolagem na mesma tela competem em vez de somar. O que não se faz é apagar só a .esc-nota.
Copy com destaque palavra a palavra(experimental) — trocar o [data-rv] do .esc-texto pelo efeitos/destaque-texto-scroll, que acende a prosa conforme a mesma rolagem que escova o vídeo. É composição, não dependência: este markup não carrega nenhum gancho daquele componente, e quem não colar o outro não perde nada.
Dois vídeos numa página(experimental) — duas seções [data-esc]. O script parte da raiz de cada uma; o custo é de rede, não de quadro.
Escova em sequência de imagens(experimental) — trocar o <video> por um <canvas> alimentado por webp numerados. Some o destravamento do iOS e some o GOP; entram dezenas de requisições e um pré-carregador.
Gotchas
overflow num ancestral do sticky mata o sticky, em silêncio. A <section> e o .esc-trilho ficam em overflow:visible de propósito: qualquer outro valor prende o palco a um scrollport que não rola, e ele passa a descer junto com a página. Não há erro, não há aviso — só uma dobra que "não funciona". Quem recorta é o .esc-palco, que é o próprio elemento que gruda. Mesmo achado do midia/faixa-horizontal-pinada.
Escrever currentTime a cada quadro engasga. Cada escrita é um pedido de seek ao decodificador, não um ponteiro que se move. Sessenta por segundo, a maioria pedindo o quadro que já está na tela, enfileiram trabalho que o motor não descarta: a imagem trava alguns décimos atrás do dedo e depois pula. O limiar é 0,025s — menos que um quadro a 30fps (0,033s) e a 25fps (0,04s), então nada que exista no arquivo é pulado; o que se corta é só o pedido redundante. As duas pontas (0 e 1) são exceção e escrevem sempre, senão a dobra abre e fecha com o quadro errado congelado.
Nunca mire em duration cravado. A duração declarada no contêiner costuma passar alguns milissegundos do último quadro de vídeo, e um seek para lá devolve o elemento ao estado ended — que em alguns motores repinta o pôster. A dobra terminaria saltando para a foto. A mira é duration - 0,05s.
Só escove depois de loadedmetadata, e confira que a duração é finita. Antes disso duration é NaN; em transmissão ao vivo é Infinity. O script usa isFinite e não Number.isFinite porque o §1 pede ES5-safe, e os dois só diferem ao coagir string — duration é sempre number. E os metadados podem já ter chegado antes de o script rodar (cache, preload="auto", script no fim do <body>): sem a conferência inicial o evento nunca mais dispararia e a peça ficaria no pôster para sempre.
No iOS o vídeo só aceita ser escovado depois de um play()/pause(). Um <video> que nunca tocou não tem pipeline de decodificação montado no Safari de iPhone, e escrever currentTime nele não repinta nada: fica o pôster, e o console não diz uma palavra. O script tenta duas vezes — assim que os metadados chegam (com muted a política costuma permitir) e, se aquela foi recusada, no primeiro pointerdown dentro da seção, que é gesto de verdade. Falhando as duas, não há terceira e não há erro: fica o pôster parado com a copy inteira legível por cima. É caminho de falha desenhado, e é por isso que o pôster tem de ser um quadro do próprio filme.
preload="auto", e as outras duas não servem. Escovar é pedir quadros fora de ordem: com metadata a agulha procura bytes que não chegaram, e o resultado é o pôster piscando no meio da dobra. O preço é o arquivo inteiro na rede — daí a duração cravada e o veto de economia de dados.
O canto que fica pagando: sem JavaScript o script não roda e ninguém cancela o preload, então o arquivo é baixado para nunca ser usado. Não há conserto por markup (trocar o atributo quebraria o caminho principal), e é uma das razões de o orçamento do asset ser 2,5 MB e não 20. Sob movimento reduzido e sob economia de dados o script cancela — ali o desperdício não acontece.
Não tem botão de pausa, e isso é decisão. A WCAG 2.2.2 exige um jeito de parar o que anda sozinho por mais de cinco segundos. Aqui nada anda sozinho: não há autoplay, não há loop, não há relógio; o vídeo só se move enquanto a pessoa move a página, e parar de rolar já é a pausa. Um botão que congelasse a escova só tiraria dela o controle que ela já tem.
O caso em que ele passa a ser obrigatório: somar qualquer movimento automático a esta dobra — o vídeo voltando a tocar sozinho no fim do trilho, uma legenda em rodízio, um contador. Aí entra o .btn-play do core com o LIB.pausavel, como no hero/slideshow-cinematico.
role="progressbar" e não região viva, e a escolha é sobre o que o leitor de tela faz com cada um. Este número muda a cada quadro de rolagem: um aria-live falaria por cima de tudo, sem parar, enquanto a pessoa desce a página — o pior tipo de anúncio, o que impede de ouvir o resto. O progressbar é consultado quando a pessoa quer, e o aria-valuenow fica sempre correto. O script só escreve quando o inteiro muda. A régua visível (Início · % · Fim) é aria-hidden por ser a duplicata do que o progressbar já reporta; a ressalva não é escondida.
O modo estático é o padrão do CSS, não um fallback..is-preso é opt-in por classe, escrita pelo script. Sem JavaScript — bloqueado, quebrado, ainda não executado — a seção é uma dobra comum com o pôster e a copy, em vez de 3,2 telas de vazio. É também por isso que não há @media(scripting:none) no estilo.css: não há portão de classe a destravar.
A leitura de progresso nasce [hidden]. Sem script seria um mostrador travado em 0%, que é pior que mostrador nenhum. A regra .esc-leitura[hidden]{display:none} está lá pelo §6.5 — e a armadilha aqui chegaria pela porta de trás: basta alguém somar um display nessa classe para o bloco nascer visível e morto, sem nada acusar.
O curso é trilho.offsetHeight - palco.offsetHeight, e não a altura da seção. Usar a altura do trilho sozinha faria o vídeo terminar antes de o palco soltar; usar a da seção erraria pelo padding. O salto apareceria justamente na emenda com a dobra seguinte, que é onde ninguém procura.
Movimento reduzido / toque
prefers-reduced-motion: reduce não escova. O script sai antes de pôr .is-preso: a seção vira uma dobra comum com o pôster, a copy inteira legível e a leitura de progresso escondida — um progresso sem o que ele mede é ruído. Prender a rolagem da página a uma agulha de vídeo é exatamente o movimento imposto que a preferência pede para evitar, e por isso não basta encurtar o trilho.
Há ainda um @media(prefers-reduced-motion:reduce) no estilo.css desligando o modo preso mesmo que a classe apareça: cinto e suspensório para quem colar .is-preso à mão ou escrever um script futuro que a ponha antes de consultar a preferência.
Economia de dados (navigator.connection.saveData, ou effectiveType 2G) tem o mesmo efeito, e por razão diferente: preload="auto" é o arquivo inteiro na rede, que é justamente o byte que a pessoa pediu para não gastar. O script cancela o preload e devolve a dobra estática.
Toque: o gesto é o mesmo de sempre — rolar. Não há hover, não há arrasto e não há alvo de toque nesta peça além dos links da página. No celular o trilho encurta para 2,6 telas, porque o arremesso de dedo é longo: com 2,2 telas de curso o filme de seis segundos raramente chegava ao fim antes de a pessoa passar direto.
O que foi medido
Chrome headless (node bin/medir.js), com um <video> de 6,00 s simulado no lugar do arquivo que a biblioteca não versiona.
Medida
1440×900
390×844
Altura do trilho / do palco
2880 / 900 px (3,2 telas)
2194 / 844 px (2,6 telas)
Curso preso
1.980 px
1.350 px
Rolagem por segundo de filme
330 px/s
225 px/s
currentTime em 0,2 / 0,5 / 0,8
1,190 / 2,975 / 4,760 s — exato
—
aria-valuenow e % visível nas mesmas frações
20 / 50 / 80
—
Pontas
0 → 0,000 s · 1 → 5,950 s (= duration − 0,05)
—
Seeks em 10 micro-rolagens de 2 px
2 (o limiar cortou 8)
—
Overflow horizontal
ok
ok
Console
limpo
limpo
Com --reduce e com --sem-js, nos dois tamanhos: sem .is-preso, palco static, trilho igual ao palco (823 px no desktop, 658 px no celular, contra 2880/2194 no modo preso), leitura de progresso display:none, título e prosa em opacity:1 sem filtro nem transform, pôster pintado. Sob --reduce o script ainda cancela o preload.
Histórico
Projeto
Onde
Data
Diferenças
referência: skinboost-design-review.vercel.app
dobra #manifesto
2026-09-21
GSAP/ScrollTrigger com scrub → conta própria em LIB.onScroll; three.js e ícones Phosphor fora; assets de Figma → pôster de showroom/demo/
Normalizações aplicadas na extração: prefixo esc- em toda classe; cor só por token (--dark-rgb no scrim e no véu, --accent-ink no realce do título); reveal pelos ganchos do core (data-rv, .ttl) em vez de observer próprio; um único laço de scroll (LIB.onScroll); primitivas do core em vez de cópia (.wrap, .eyebrow, .display-xl, .nota, .grain); copy reescrita para manifesto de assessoria.
O que a tradução acrescentou e o original não tinha: movimento reduzido desligando a escova inteira; modo estático como padrão do CSS, para a dobra sobreviver sem JavaScript; veto de economia de dados; role="progressbar" com aria-valuenow/aria-valuetext; a ressalva de compliance do percentual; e o limiar de escrita do currentTime, que é o que separa escovar de engasgar.
Divergências conhecidas vs. a referência: o original escova um filme com cortes e usa a mesma dobra para três blocos de copy em sequência; aqui a copy é uma só, porque escova não respeita corte e porque trocar texto no meio do trilho pediria um segundo mecanismo de rolagem dentro do primeiro.