← Biblioteca componentes/midia/video-escovado-scroll/ficha.md CódigoTela cheia
midia/video-escovado-scroll

Vídeo escovado pelo scroll

🟢 estavel complexidade alta prefixo esc- video scrub sticky scroll pinada manifesto poster playsinline progresso movimento-reduzido economia-de-dados acessibilidade compliance institucional

Incluir · as linhas que se colam num site novo, depois do core (CLAUDE.md §10)

<link rel="stylesheet" href="componentes/midia/video-escovado-scroll/estilo.css">
<script src="componentes/midia/video-escovado-scroll/script.js"></script>

markup.html · 191 linhas · 9,8 KB

<!-- ==========================================================================
     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.
     ========================================================================== -->

<section class="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. -->
  <div class="esc-trilho" data-esc-trilho>
    <div class="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. -->
      <div class="esc-media">
        <video class="esc-video" data-esc-video
               muted playsinline preload="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>
        <div class="esc-scrim"></div>
        <div class="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". -->
      <div class="esc-veu" aria-hidden="true"></div>

      <div class="wrap esc-body">

        <div class="esc-copy">
          <span class="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. -->
          <h2 class="display-xl esc-title ttl">
            Patrimônio não se conduz no <em>improviso</em>.
          </h2>
        </div>

        <div class="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. -->
          <div class="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. -->
          <div class="esc-leitura" data-esc-leitura hidden>
            <div class="esc-barra" data-esc-barra
                 role="progressbar"
                 aria-label="Posição de leitura nesta dobra"
                 aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
                 aria-valuetext="0% desta dobra">
              <span class="esc-barra-fill"></span>
            </div>

            <p class="esc-marcas" aria-hidden="true">
              <span class="esc-marca">Início</span>
              <span class="esc-pct" data-esc-pct>0%</span>
              <span class="esc-marca">Fim</span>
            </p>

            <p class="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,#000 0%,rgba(0,0,0,.9) 48%,transparent 100%);
  mask-image:linear-gradient(to top,#000 0%,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:1 1 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:0 0 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:0 50%;
  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) return false;
    if (c.saveData) return true;
    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) return 0;
      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ôster
      var 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 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

Quando não usar

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.


Como usar

<link rel="stylesheet" href="componentes/midia/video-escovado-scroll/estilo.css">
<script src="componentes/midia/video-escovado-scroll/script.js"></script>

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


Gotchas


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.