← Biblioteca componentes/hero/slideshow-cinematico/ficha.md CódigoTela cheia
hero/slideshow-cinematico

Hero em slideshow cinematográfico

🟢 estavel complexidade alta prefixo cine- hero above-the-fold slideshow carrossel autoplay ken-burns zoom-lento fusao-com-blur abas barra-de-progresso palavra-a-palavra imagem swipe teclado escuro

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

<link rel="stylesheet" href="componentes/hero/slideshow-cinematico/estilo.css">
<script src="componentes/hero/slideshow-cinematico/script.js"></script>

markup.html · 219 linhas · 10,8 KB

<!-- ==========================================================================
     SLIDESHOW CINEMATOGRÁFICO — três fotos de tela cheia que trocam sozinhas
     Prefixo: cine-
     Requer: core (tokens.css, base.css, motion.js) + estilo.css e script.js
     motion.js passou a ser NECESSÁRIO em 2026-09-21: é dele que vem o
     LIB.pausavel, a fiação do botão de pausa. Sem ele a dobra ainda abre e
     troca sozinha, mas o botão fica [hidden] — e a WCAG 2.2.2 pede que ele
     exista. Nenhum gancho de ENTRADA do core é usado aqui — a dobra abre com
     a página, não ao entrar na tela (ver "coreografia").

     O QUE É. Um hero de tela cheia com três slides. Cada slide é uma foto
     sangrada em zoom lento (Ken Burns), uma headline que entra palavra a
     palavra e um botão próprio. No pé, uma barra de abas numeradas com uma
     linha de progresso que enche no tempo do slide. Troca sozinho a cada 7s;
     clicar numa aba, as setas do teclado ou arrastar no toque trocam na hora.

     DUAS PILHAS, UMA SÓ VERDADE. A foto e o texto de um slide vivem em lugares
     diferentes do DOM porque têm caixas diferentes: a foto sangra até a borda
     (é filha absoluta de .cine-media) e o texto vive dentro do .wrap, que é a
     única regra que controla a coluna (§4). O que amarra as duas é o ÍNDICE:
     o script move .is-on pelo slide, pela cópia e pela aba de mesmo número.
     Nenhuma margem negativa e nenhum 100vw.

     ARIA: CARROSSEL COM ABAS. É o padrão "carousel with tabbed slide picker"
     do W3C APG. A seção declara aria-roledescription="carrossel" e um nome;
     a barra do pé é um role="tablist" de verdade; o painel de cada aba é a
     CÓPIA, não a foto — o painel tem de ser o que se lê e o que se clica, e
     a foto é o fundo que acompanha. A foto do slide inativo sai da árvore de
     acessibilidade junto com a cópia dele.

     SEM ID NO MARKUP. aria-controls e aria-labelledby precisam de id, e id
     em markup de biblioteca colide na primeira página que repetir a dobra.
     Quem cria os ids — únicos por instância — e amarra os dois atributos é o
     script, na inicialização. O §2 do contrato pede exatamente isto: id só
     quando for âncora de navegação da página.

     SEM JS. O primeiro slide já nasce com .is-on, então a dobra abre inteira:
     foto, olho, headline, apoio e botão. O que não existe sem script é a
     troca — e por isso a barra de abas só aparece quando o script marca a
     raiz com .is-live. Botão que não faz nada é pior que botão que não existe.

     TRÊS FOTOS EM eager. As três entram no DOM e as três carregam de uma vez
     (sem loading="lazy"): a fusão da primeira troca acontece 7s depois de
     abrir, e imagem que começa a baixar na hora da troca pisca. O preço está
     na ficha, com o teto de peso por foto.
     ========================================================================== -->

<section class="cine sec-dark" data-cine
         aria-roledescription="carrossel"
         aria-label="Frentes de trabalho da Liberta">

  <!-- MÍDIA. Os três slides ocupam a mesma caixa, empilhados. O grão é do
       core e fica UMA vez, fora dos slides: ele é textura da dobra, não da
       foto, e dentro de cada slide seria desenhado três vezes e esmaeceria
       junto com a troca. -->
  <div class="cine-media">
    <ul class="cine-slides">

      <li class="cine-slide is-on" data-cine-slide>
        <img class="cine-img" src="../../../showroom/demo/ph-wide-1.webp"
             alt="Casal caminhando por uma estrada de terra entre vinhedos, ao entardecer"
             style="--cine-pos:64% 46%" decoding="async">
        <span class="cine-scrim"></span>
      </li>

      <li class="cine-slide" data-cine-slide aria-hidden="true">
        <img class="cine-img" src="../../../showroom/demo/ph-wide-3.webp"
             alt="Veleiro sozinho em mar aberto, sob céu carregado"
             style="--cine-pos:64% 54%" decoding="async">
        <span class="cine-scrim"></span>
      </li>

      <li class="cine-slide" data-cine-slide aria-hidden="true">
        <img class="cine-img" src="../../../showroom/demo/ph-wide-2.webp"
             alt="Homem de terno observando a cidade pela janela de um escritório"
             style="--cine-pos:40% 44%" decoding="async">
        <span class="cine-scrim"></span>
      </li>

    </ul>
    <span class="grain"></span>
  </div>

  <div class="wrap cine-body">

    <!-- AS TRÊS CÓPIAS OCUPAM A MESMA CÉLULA DE GRID e são alinhadas pela
         BASE: assim o botão de todos os slides cai na mesma linha e a dobra
         não pula de altura a cada troca. A cópia inativa continua no fluxo,
         invisível — é ela que reserva a altura da mais alta. -->
    <div class="cine-copies" data-cine-copies>

      <div class="cine-copy is-on" data-cine-copy
           role="tabpanel" aria-roledescription="slide">
        <span class="eyebrow cine-eyebrow">Planejamento sucessório</span>
        <h1 class="cine-title">
          <span class="cine-w" style="--cine-i:0">A</span>
          <span class="cine-w" style="--cine-i:1">sucessão</span>
          <span class="cine-w" style="--cine-i:2">que</span>
          <span class="cine-w" style="--cine-i:3">começa</span>
          <span class="cine-w" style="--cine-i:4">antes</span>
          <span class="cine-w" style="--cine-i:5">da</span>
          <span class="cine-w" style="--cine-i:6">urgência.</span>
        </h1>
        <p class="cine-desc">
          Holding, testamento e um acordo que a família entende — feitos com
          calma, enquanto ainda é escolha.
        </p>
        <div class="cine-act">
          <a class="btn btn-primary" href="#diagnostico">Agende seu diagnóstico</a>
        </div>
      </div>

      <div class="cine-copy" data-cine-copy
           role="tabpanel" aria-roledescription="slide" aria-hidden="true">
        <span class="eyebrow cine-eyebrow">Estrutura internacional</span>
        <h1 class="cine-title">
          <span class="cine-w" style="--cine-i:0">Patrimônio</span>
          <span class="cine-w" style="--cine-i:1">no</span>
          <span class="cine-w" style="--cine-i:2">exterior,</span>
          <span class="cine-w" style="--cine-i:3">com</span>
          <span class="cine-w" style="--cine-i:4">o</span>
          <span class="cine-w" style="--cine-i:5">pé</span>
          <span class="cine-w" style="--cine-i:6">no</span>
          <span class="cine-w" style="--cine-i:7">chão.</span>
        </h1>
        <p class="cine-desc">
          Estrutura offshore, conta e custódia fora do país, com relatório
          consolidado: a parte técnica resolvida antes da primeira remessa.
        </p>
        <div class="cine-act">
          <a class="btn btn-primary" href="#internacional">Ver a estrutura</a>
        </div>
      </div>

      <div class="cine-copy" data-cine-copy
           role="tabpanel" aria-roledescription="slide" aria-hidden="true">
        <span class="eyebrow cine-eyebrow">Alocação independente</span>
        <h1 class="cine-title">
          <span class="cine-w" style="--cine-i:0">Uma</span>
          <span class="cine-w" style="--cine-i:1">carteira</span>
          <span class="cine-w" style="--cine-i:2">que</span>
          <span class="cine-w" style="--cine-i:3">atravessa</span>
          <span class="cine-w" style="--cine-i:4">instituições.</span>
        </h1>
        <p class="cine-desc">
          Sem vínculo com bancos ou corretoras. A alocação é decidida por
          objetivo e distribuída entre instituições — e o relatório chega
          consolidado, numa página só.
        </p>
        <div class="cine-act">
          <a class="btn btn-primary" href="#carteira">Falar com um assessor</a>
        </div>
      </div>

    </div>

    <!-- A BARRA. O tablist tem só abas dentro, como o padrão manda; o botão
         de pausa é irmão dele. A pausa existe porque a dobra troca sozinha
         por mais de cinco segundos, e quem não usa "reduzir movimento" no
         sistema precisa de um jeito explícito de parar (WCAG 2.2.2). -->
    <div class="cine-bar">

      <div class="cine-tabs" data-cine-tabs role="tablist"
           aria-label="Frentes de trabalho">

        <button class="cine-tab is-on" type="button" data-cine-tab
                role="tab" aria-selected="true" tabindex="0">
          <span class="cine-tab-bar"><span class="cine-tab-fill"></span></span>
          <span class="cine-tab-n">01</span>
          <span class="cine-tab-t">Sucessão</span>
        </button>

        <button class="cine-tab" type="button" data-cine-tab
                role="tab" aria-selected="false" tabindex="-1">
          <span class="cine-tab-bar"><span class="cine-tab-fill"></span></span>
          <span class="cine-tab-n">02</span>
          <span class="cine-tab-t">Internacional</span>
        </button>

        <button class="cine-tab" type="button" data-cine-tab
                role="tab" aria-selected="false" tabindex="-1">
          <span class="cine-tab-bar"><span class="cine-tab-fill"></span></span>
          <span class="cine-tab-n">03</span>
          <span class="cine-tab-t">Carteira</span>
        </button>

      </div>

      <!-- .btn-play é a primitiva do core (base.css): os dois desenhos dentro
           do botão, com só um visível por vez, comandados por [data-pausado]
           no PRÓPRIO botão — e quem escreve esse atributo é o LIB.pausavel.
           A .cine-play que sobra desenha o disco: ela não veste .btn-disc
           porque aqui a borda e a tinta são medidas contra FOTO, em --light, e
           não contra o fundo da seção (--rule-2 sumiria sobre a imagem).

           O BOTÃO NASCE [hidden] e quem o mostra é o core. Sob movimento
           reduzido o script simplesmente não chama o LIB.pausavel, e ele fica
           como nasceu: não há troca automática para pausar, e botão que não
           comanda nada é pior que botão nenhum.

           OS DOIS RÓTULOS MORAM AQUI, em data-rot-*. Até 2026-09-21 eles eram
           duas strings cravadas dentro do script.js — copy em JavaScript, onde
           nenhum redator vai procurar. -->
      <button class="btn-play cine-play" type="button" data-cine-play hidden
              data-rot-pausar="Pausar a troca automática"
              data-rot-tocar="Retomar a troca automática"
              aria-label="Pausar a troca automática">
        <svg class="btn-play-pausa" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
          <rect x="2" y="1.5" width="2.6" height="9" rx="1" fill="currentColor"/>
          <rect x="7.4" y="1.5" width="2.6" height="9" rx="1" fill="currentColor"/>
        </svg>
        <svg class="btn-play-tocar" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
          <path d="M2.8 1.6 10.2 6 2.8 10.4Z" fill="currentColor" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
        </svg>
      </button>

    </div>
  </div>
</section>

estilo.css · 460 linhas · 18,7 KB

/* ==========================================================================
   SLIDESHOW CINEMATOGRÁFICO — prefixo cine-
   Hero de tela cheia com três fotos que trocam sozinhas, headline por
   palavra e barra de abas com progresso.

   Tokens usados:
     --dark-rgb           as duas camadas de scrim sobre a foto
     --light              texto e abas — a dobra é uma ILHA ESCURA (ver abaixo)
     --accent             linha de progresso da aba e o número dela
     --display            tipografia da headline e das abas
     --ease               curva do projeto
     --nav-h              folga para o menu fixo passar por cima da foto
     --radius-pill        o disco do botão de pausa
   A coluna vem do .wrap do core, então --max e --pad não são lidos aqui.

   ILHA ESCURA. Tudo que está sobre a foto lê --light, e não --fg (§8 do
   brief, §6 do contrato): assim a mesma dobra roda numa página de escopo
   claro sem virar texto escuro sobre foto escura. Quem depende do escopo de
   tom aqui é só o fundo da seção, que aparece atrás das fotos enquanto elas
   carregam.

   UMA DURAÇÃO SÓ. --cine-dur é o relógio da dobra: o zoom da foto, a linha
   de progresso da aba e o setTimeout do script leem os MESMOS 7s. O script
   lê o valor por getComputedStyle na raiz — nunca há dois números para
   manter em sincronia (ver o gotcha na ficha).
   ========================================================================== */

.cine{
  /* Relógio da dobra. Tempo que cada slide fica em cena — e, junto, a
     duração do zoom e da linha de progresso. Abaixo de 5s não dá para ler a
     headline inteira; acima de 10s a barra parece travada. */
  --cine-dur:7s;

  /* Fusão entre uma foto e a próxima. */
  --cine-swap:1.2s;
  /* Fusão do bloco de texto. Mais curta que a da foto de propósito: o texto
     precisa sair antes de o novo entrar, senão as duas headlines se
     sobrepõem legíveis por meio segundo. */
  --cine-fade:.45s;

  /* Desfoque da foto que entra e da que sai. É o que dá a leitura de
     "cinema" em vez de "slideshow": a foto se resolve, não aparece. Custa
     caro em tela cheia — o bloco de mobile lá embaixo baixa o valor. */
  --cine-blur:16px;

  /* Ken Burns: escala no fim do slide. 1.08 em 7s é ~1,1% por segundo, no
     limite do que o olho lê como "vivo" sem enjoar. */
  --cine-zoom:1.08;

  /* Onda da headline: atraso da primeira palavra e passo entre palavras. */
  --cine-word-base:.1s;
  --cine-word-lag:54ms;

  --cine-title:clamp(34px,5vw,72px);

  position:relative;
  overflow:hidden;
  /* Duas declarações, não uma: svh é a altura da janela SEM contar a barra
     do navegador que se recolhe, e é o que evita a dobra "vazar" um pedaço
     no celular. Onde svh não existe, a primeira linha continua valendo.
     Não usa .sec-pad: a dobra é uma tela inteira, e o respiro é o
     padding do .cine-body. */
  min-height:100vh;
  min-height:100svh;
}

/* ---------------------------------------------------------------- mídia */
.cine-media{position:absolute;inset:0;overflow:hidden}
.cine-slides{position:absolute;inset:0}

/* O slide inativo é invisível E fora do fluxo de pintura: sem o
   visibility:hidden ele continuaria sendo um retângulo de tela cheia com
   filter:blur() aplicado, e o §11 do brief é explícito — estado de repouso
   não paga quadro. O visibility só troca DEPOIS da fusão (delay igual a
   --cine-swap), senão a foto que sai desaparece de uma vez. */
.cine-slide{
  position:absolute;inset:0;
  opacity:0;visibility:hidden;
  filter:blur(var(--cine-blur));
  transition:opacity var(--cine-swap) var(--ease),
             filter var(--cine-swap) var(--ease),
             visibility 0s linear var(--cine-swap);
}
.cine-slide.is-on{
  opacity:1;visibility:visible;
  filter:blur(0);
  transition:opacity var(--cine-swap) var(--ease),
             filter var(--cine-swap) var(--ease),
             visibility 0s;
}
/* .is-out é a foto que está saindo. Ela precisa continuar visível durante a
   fusão — e continuar ZOOMANDO: ver o bloco do Ken Burns. */
.cine-slide.is-out{visibility:visible}

/* O corte é por slide, no markup (style="--cine-pos:60% 46%"), e não uma
   regra por posição: retrato em pé, veleiro no terço direito e sala com
   janela à esquerda não sobrevivem ao mesmo valor. Mesmo recurso do
   conteudo/pilares-texto-na-foto e do midia/carrossel-perfis. */
.cine-img{
  position:absolute;inset:0;
  width:100%;height:100%;
  object-fit:cover;
  object-position:var(--cine-pos,50% 46%);
  /* A escala parte daqui e a animação a substitui. Escrita também no estado
     de repouso para a foto sem animação (sem JS, ou movimento reduzido) não
     nascer num tamanho diferente do primeiro quadro do zoom. */
  transform:scale(1);
}

/* Dois gradientes por slide, e não um só para a mídia inteira: o desfoque da
   fusão é aplicado no SLIDE, então o scrim tem de estar dentro dele. Um
   scrim por fora ficaria nítido sobre uma foto desfocada, e o retângulo
   apareceria. O de baixo ancora o texto; o da esquerda dá contraste à
   coluna; a última parada do vertical escurece o topo, onde passa o menu. */
.cine-scrim{
  position:absolute;inset:0;
  background:
    linear-gradient(to top,
      rgba(var(--dark-rgb),.94) 0%,
      rgba(var(--dark-rgb),.66) 32%,
      rgba(var(--dark-rgb),.20) 62%,
      rgba(var(--dark-rgb),.46) 100%),
    linear-gradient(to right,
      rgba(var(--dark-rgb),.74) 0%,
      rgba(var(--dark-rgb),.30) 44%,
      rgba(var(--dark-rgb),0) 80%);
}

/* ---------------------------------------------------------------- corpo */
/* Texto e abas ancorados embaixo, como no hero/full-bleed-media: o alto da
   tela é da foto. A linha 1fr empurra a barra de abas para o pé. */
.cine-body{
  position:relative;z-index:2;
  min-height:100vh;
  min-height:100svh;
  display:grid;
  grid-template-rows:1fr auto;
  padding-top:calc(var(--nav-h) + clamp(40px,8vh,88px));
  padding-bottom:clamp(28px,4.5vh,44px);
}

.cine-copies{display:grid;align-items:end;padding-bottom:clamp(34px,6vh,64px)}

/* Todas as cópias na MESMA célula, alinhadas pela base. A célula fica com a
   altura da cópia mais alta, e é isso que impede a barra de abas de pular
   de lugar quando um slide tem headline de duas linhas e outro de três. */
.cine-copy{
  grid-area:1 / 1;
  align-self:end;
  max-width:min(720px,100%);
  opacity:0;visibility:hidden;
  transition:opacity var(--cine-fade) var(--ease),
             visibility 0s linear var(--cine-fade);
}
.cine-copy.is-on{
  opacity:1;visibility:visible;
  transition:opacity var(--cine-fade) var(--ease),visibility 0s;
}

/* O olho é a primitiva .eyebrow do core; esta classe só troca a tinta,
   porque aqui ele está sobre foto. Seletor duplo (.cine .cine-eyebrow) de
   propósito: .eyebrow e .cine-eyebrow têm a mesma especificidade, e o §6.4
   do contrato proíbe depender de qual folha carregou primeiro. Mesma
   solução do .hgal-cap .hgal-type. */
.cine .cine-eyebrow{color:color-mix(in srgb,var(--light) 66%,transparent)}

/* Não usa .display-xl do core: aquela primitiva é a peça gráfica de uma
   dobra de conteúdo, com corpo que enche a coluna e entrelinha .95. Esta
   headline divide a tela com uma foto e um botão, e passa a maior parte do
   tempo em duas linhas — são dois desenhos diferentes. */
.cine-title{
  font-family:var(--display);font-weight:700;
  font-size:var(--cine-title);
  line-height:1.06;letter-spacing:-.03em;
  color:var(--light);
  max-width:17ch;
  margin-top:20px;
}
/* inline-block é o que permite transformar palavra a palavra. O espaço entre
   os spans continua sendo espaço de texto — não vire flex aqui, ou a quebra
   de linha deixa de acompanhar o corpo da fonte. */
.cine-w{display:inline-block}

.cine-desc{
  font-size:clamp(15px,1.35vw,18px);line-height:1.6;
  color:color-mix(in srgb,var(--light) 78%,transparent);
  max-width:52ch;
  margin-top:22px;
}

.cine-act{margin-top:30px}

/* ---------------------------------------------------------------- entrada */
/* A COREOGRAFIA REENTRA A CADA SLIDE. Por isso não é data-rv nem data-split:
   os dois ganchos do core disparam UMA vez, quando o bloco entra na tela, e
   se desobservam. Aqui o mesmo texto tem de entrar de novo toda vez que a
   cópia recebe .is-on — o que só uma transição por classe faz.

   Três estados, e a ordem é o que garante a página sem JS:
     1. em repouso, tudo VISÍVEL — sem script, a dobra abre pronta;
     2. .is-live (posta pelo script antes da primeira pintura) esconde;
     3. .in + .is-on revela, com o atraso por palavra.

   Os atrasos moram no estado 3, e não no 1: assim a onda escalona na
   ENTRADA e a saída é imediata, escondida atrás da fusão da cópia. */
.cine.is-live .cine-eyebrow,
.cine.is-live .cine-w,
.cine.is-live .cine-desc,
.cine.is-live .cine-act{
  opacity:0;
  filter:blur(12px);
  transform:translateY(20px);
  transition:opacity .9s var(--ease),
             filter .9s var(--ease),
             transform .9s var(--ease);
}

.cine.is-live.in .cine-copy.is-on .cine-eyebrow,
.cine.is-live.in .cine-copy.is-on .cine-w,
.cine.is-live.in .cine-copy.is-on .cine-desc,
.cine.is-live.in .cine-copy.is-on .cine-act{
  opacity:1;filter:blur(0);transform:none;
}
.cine.is-live.in .cine-copy.is-on .cine-eyebrow{transition-delay:0s}
.cine.is-live.in .cine-copy.is-on .cine-w{
  transition-delay:calc(var(--cine-word-base) + var(--cine-i,0) * var(--cine-word-lag));
}
.cine.is-live.in .cine-copy.is-on .cine-desc{transition-delay:.42s}
.cine.is-live.in .cine-copy.is-on .cine-act{transition-delay:.56s}

/* Quem pediu menos movimento recebe o texto inteiro, parado, sem onda e sem
   desfoque. A troca de slide continua existindo — ela é comando da pessoa,
   não movimento imposto (o script desliga o relógio; ver script.js).
   Colado na regra que desliga, como manda o §6.5. */
@media(prefers-reduced-motion:reduce){
  .cine.is-live .cine-eyebrow,
  .cine.is-live .cine-w,
  .cine.is-live .cine-desc,
  .cine.is-live .cine-act{
    opacity:1;filter:none;transform:none;transition:none;
  }
}

/* ---------------------------------------------------------------- ken burns */
/* A MESMA regra vale para o slide que entra e para o que sai. Não é
   duplicação: é o que impede a foto que sai de dar um pulo.

   `animation … forwards` só segura o estado final enquanto a regra continuar
   valendo. Tirar .is-on da foto que sai apagaria o animation-name, o
   navegador voltaria ao transform de repouso (scale 1) e a foto encolheria
   0,08 em um quadro, bem no meio da fusão. Com .is-out a regra continua
   casando, o zoom segue correndo enquanto ela esmaece, e o script só tira a
   classe depois de --cine-swap, quando a foto já está invisível.

   O outro lado da moeda está no script: uma foto que volta a ser .is-on sem
   ter perdido o animation-name NÃO reinicia o zoom sozinha. Ver
   reiniciarAnim() no script.js. */
.cine.in .cine-slide.is-on .cine-img,
.cine.in .cine-slide.is-out .cine-img{
  animation:cineKen var(--cine-dur) linear forwards;
}

@keyframes cineKen{
  from{transform:scale(1)}
  to{transform:scale(var(--cine-zoom,1.08))}
}

/* Pausa. A especificidade aqui é maior de propósito: a regra acima usa a
   SHORTHAND `animation`, e toda shorthand zera as suas longhands — inclusive
   animation-play-state. Uma regra de pausa mais fraca seria escrita e depois
   apagada pela shorthand, e o botão de pausa "não funcionaria" sem erro
   nenhum no console. Medido. */
.cine.is-paused.in .cine-slide.is-on .cine-img,
.cine.is-paused.in .cine-slide.is-out .cine-img{animation-play-state:paused}

/* Sem zoom para quem pediu menos movimento — e a foto fica no tamanho de
   repouso, que é o mesmo primeiro quadro do zoom. */
@media(prefers-reduced-motion:reduce){
  .cine.in .cine-slide.is-on .cine-img,
  .cine.in .cine-slide.is-out .cine-img{animation:none}
  /* Fusão só de opacidade, mais curta, e sem o desfoque. */
  .cine-slide{
    filter:none;
    transition:opacity .4s linear,visibility 0s linear .4s;
  }
  .cine-slide.is-on{
    filter:none;
    transition:opacity .4s linear,visibility 0s;
  }
}

/* ---------------------------------------------------------------- abas */
/* A barra só existe com JavaScript: sem ele as abas seriam três botões que
   não fazem nada. Quem acende é o script, na inicialização (.is-live). */
.cine-bar{display:none}
.cine.is-live .cine-bar{
  display:flex;align-items:flex-start;
  gap:clamp(14px,2.4vw,30px);
}

.cine-tabs{
  display:flex;flex:1 1 auto;min-width:0;
  gap:clamp(12px,2.4vw,32px);
}

/* Botão de verdade, sem cara de botão: o reset é local porque o .btn do core
   é outra coisa (pílula de rótulo, com fundo e padding fixos). Cada aba
   ocupa a mesma fração da barra — é o que faz as três linhas de progresso
   lerem como uma régua repartida. */
.cine-tab{
  flex:1 1 0;min-width:0;
  display:grid;
  grid-template-columns:auto minmax(0,1fr);
  align-items:baseline;
  column-gap:10px;row-gap:14px;
  padding:0;border:0;background:none;
  text-align:start;
  color:color-mix(in srgb,var(--light) 54%,transparent);
  transition:color .35s var(--ease);
}
.cine-tab:hover{color:var(--light)}
.cine-tab.is-on{color:var(--light)}

/* O trilho de 2px de cada aba, lado a lado, é o filete do pé da dobra —
   por isso não há border-top na barra: seria uma linha em cima da outra. */
.cine-tab-bar{
  grid-column:1 / -1;
  height:2px;
  background:color-mix(in srgb,var(--light) 20%,transparent);
  overflow:hidden;
}
.cine-tab-fill{
  display:block;height:100%;
  background:var(--accent);
  transform:scaleX(0);transform-origin:left center;
}
/* Enche no tempo do slide. Mesma duração, mesma fonte: --cine-dur. */
.cine.in .cine-tab.is-on .cine-tab-fill{
  animation:cineFill var(--cine-dur) linear forwards;
}
@keyframes cineFill{
  from{transform:scaleX(0)}
  to{transform:scaleX(1)}
}
/* Mesma história da pausa do Ken Burns: precisa vencer a shorthand. */
.cine.is-paused.in .cine-tab.is-on .cine-tab-fill{animation-play-state:paused}

/* Sem relógio não há o que encher: a linha da aba ativa fica cheia e vira um
   marcador estático de onde a pessoa está. */
@media(prefers-reduced-motion:reduce){
  .cine.in .cine-tab.is-on .cine-tab-fill{animation:none;transform:scaleX(1)}
}

.cine-tab-n{
  font-family:var(--display);font-weight:700;
  font-size:11px;letter-spacing:.1em;
  color:color-mix(in srgb,var(--accent) 90%,transparent);
}
.cine-tab-t{
  font-family:var(--display);font-weight:700;
  font-size:clamp(11px,1vw,12.5px);
  letter-spacing:.11em;text-transform:uppercase;
  color:currentColor;
}

/* ---------------------------------------------------------------- pausa */
/* A dobra troca sozinha por mais de cinco segundos: a WCAG 2.2.2 pede um
   jeito explícito de parar, e o hover não conta (não existe no toque, e
   quem usa leitor de tela não "passa o ponteiro").

   O BOTÃO VESTE .btn-play DO CORE, e de lá vêm três coisas que este arquivo
   deixou de escrever (2026-09-21): o [hidden] desligado à mão (§6.5), a troca
   de ícone e o tamanho do desenho. Restou o disco — que aqui NÃO é o
   .btn-disc: a borda e a tinta são medidas contra a FOTO, em --light, e o
   --rule-2 do escopo sumiria sobre a imagem. O --play-ico é o parafuso da
   primitiva, e 12px é o que cabe num disco de 36. */
.cine-play{
  flex:0 0 auto;
  --play-ico:12px;
  width:36px;height:36px;padding:0;
  display:grid;place-items:center;
  background:none;
  border:1px solid color-mix(in srgb,var(--light) 24%,transparent);
  border-radius:var(--radius-pill);
  color:color-mix(in srgb,var(--light) 66%,transparent);
  transition:color .3s var(--ease),border-color .3s var(--ease);
}
.cine-play:hover{color:var(--light);border-color:color-mix(in srgb,var(--light) 52%,transparent)}
/* O [hidden] não é repetido aqui de propósito: o display:grid acima venceria o
   display:none que a folha do navegador dá ao [hidden] (§6.5, mesma pegadinha
   do .hgal-nav da galeria em scroll horizontal), e quem o desarma agora é a
   primitiva, em .btn-play[hidden] — seletor de classe MAIS atributo, que vence
   este de classe só em qualquer ordem de carregamento (§6.4). */

/* ---------------------------------------------------------------- toque */
/* Sem ponteiro não há hover: a aba ativa tem de se distinguir por outra
   coisa que não a cor de destaque do hover. A linha de progresso e a cor
   cheia do rótulo já fazem isso — aqui só se apaga o estado de hover, que
   em toque fica "grudado" no último item tocado. */
@media(hover:none){
  .cine-tab:hover{color:color-mix(in srgb,var(--light) 54%,transparent)}
  .cine-tab.is-on:hover{color:var(--light)}
}

/* ---------------------------------------------------------------- mobile */
@media(max-width:760px){
  .cine{
    --cine-title:clamp(30px,8.6vw,40px);
    /* 16px de desfoque numa foto de tela cheia é caro, e no celular é caro
       no aparelho errado. 8px mantém a leitura de fusão e corta pela metade
       a área que o navegador precisa refazer. Números na ficha. */
    --cine-blur:8px;
    --cine-swap:1s;
  }
  .cine-body{
    padding-top:calc(var(--nav-h) + 24px);
    padding-bottom:22px;
  }
  .cine-copies{padding-bottom:30px}
  .cine-title{max-width:none;margin-top:14px}
  .cine-desc{margin-top:16px;max-width:44ch}
  .cine-act{margin-top:22px}

  /* Número EM CIMA do rótulo, não no lugar dele: "01" sozinho não diz nada,
     e é o rótulo que informa para onde a aba leva. Uma coluna só por aba,
     e o rótulo pode quebrar em duas linhas sem desalinhar as vizinhas —
     todas as abas começam na mesma altura. */
  .cine-tab{grid-template-columns:minmax(0,1fr);row-gap:9px}
  .cine-tab-n{font-size:9.5px}
  .cine-tab-t{font-size:10px;letter-spacing:.06em}
  .cine-tabs{gap:12px}
  .cine.is-live .cine-bar{gap:12px}
  .cine-play{width:32px;height:32px}
}

@media(max-width:420px){
  /* Botão de largura cheia: a essa altura ele é o único caminho adiante e
     divide a tela com a barra de abas. */
  .cine-act .btn{width:100%}
}

/* ==========================================================================
   SEM JAVASCRIPT, O CONTEUDO APARECE. §5 do contrato.

   Estas regras revelam por CLASSE PROPRIA (`.cine.in`), e nao pelo `[data-rv]` do
   core — por isso o `@media (scripting: none)` do core/motion.css nao as
   alcanca: ele cobre `[data-rv]`, `.w` e `.ttl`, e mais nada.

   Sem esta excecao, com o JavaScript desligado o olho, a headline e o apoio do primeiro slide nao apareciam. Medido com
   `node bin/medir.js <preview> --sem-js`: 19 nos com texto proprio invisiveis
   ========================================================================== */
@media (scripting: none){
  .cine-w,
  .cine-eyebrow,
  .cine-desc,
  .cine-cta{opacity:1;visibility:visible;transform:none;filter:none;transition:none}
  /* O primeiro slide e' o unico que faz sentido sem script: sem JS nao ha
     troca, e mostrar os tres empilhados seria pior que mostrar um. */
  .cine-slide:first-child{opacity:1;visibility:visible}
}

script.js · 428 linhas · 18,4 KB

/* ==========================================================================
   SLIDESHOW CINEMATOGRÁFICO — o relógio, a troca e o teclado.

   O movimento é todo CSS: a fusão das fotos, o zoom (Ken Burns), a onda de
   palavras e a linha de progresso da aba são transição e @keyframes. Este
   script não desenha quadro nenhum. Ele faz seis coisas:

     1. marca a raiz com .is-live — é o que faz a barra de abas existir, e
        por isso é a PRIMEIRA linha: sem JavaScript a dobra abre inteira, com
        o primeiro slide, e sem três botões que não levam a lugar nenhum;
     2. cria os ids e amarra aria-controls / aria-labelledby (o markup não
        traz id: id de biblioteca colide na primeira página que repetir a
        dobra);
     3. abre a coreografia com .in, dois quadros depois da primeira pintura,
        e no MESMO instante liga o relógio — assim a linha de progresso e o
        setTimeout partem juntos e nunca se descolam;
     4. troca de slide sozinho a cada --cine-dur, lido do CSS;
     5. pausa quando ninguém está vendo (aba oculta, dobra fora da tela) ou
        quando alguém está lendo (ponteiro sobre o texto ou sobre as abas,
        foco dentro da seção, botão de pausa apertado) — e retoma do ponto
        exato em que parou. O botão em si é fiado pelo LIB.pausavel do core;
        aqui só se atende ao aviso de que o estado trocou;
     6. ouve clique, setas do teclado, Home/End e arrasto horizontal.

   MOVIMENTO REDUZIDO: o relógio não liga. A dobra fica no primeiro slide, e
   as abas continuam trocando — gesto pedido pela pessoa não é movimento
   imposto a ela. Quem desliga o zoom e a onda de palavras é o CSS, e o botão
   de pausa não chega a ser ligado: fica [hidden] como nasceu.

   Nenhum listener de scroll, nenhum requestAnimationFrame em laço: a única
   coisa presa ao tempo é um setTimeout, e ele é cancelado em toda pausa.
   ========================================================================== */

(function () {
  var raizes = document.querySelectorAll('[data-cine]');
  if (!raizes.length) return;                          // guard

  /* Contador de instâncias — a base dos ids únicos por dobra. */
  var conta = 0;

  /* 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;
  }

  /* "7s" e "700ms" viram milissegundos. O valor vem de getComputedStyle, que
     devolve o token do jeito que foi escrito (com o espaço da declaração),
     não um número normalizado — daí o replace antes do parseFloat. */
  function emMs(txt, padrao) {
    if (!txt) return padrao;
    txt = ('' + txt).replace(/\s/g, '');
    var n = parseFloat(txt);
    if (isNaN(n) || n <= 0) return padrao;
    return txt.indexOf('ms') > -1 ? n : n * 1000;
  }

  function iniciar(raiz) {
    var slides = [].slice.call(raiz.querySelectorAll('[data-cine-slide]'));
    var copias = [].slice.call(raiz.querySelectorAll('[data-cine-copy]'));
    var abas   = [].slice.call(raiz.querySelectorAll('[data-cine-tab]'));
    var n = Math.min(slides.length, copias.length, abas.length);
    /* Menos de dois slides não é slideshow: sai em silêncio e a dobra fica
       sendo um hero estático — inclusive sem a barra de abas, porque o
       .is-live só é escrito depois daqui. */
    if (n < 2) return;

    var caixaAbas  = raiz.querySelector('[data-cine-tabs]');
    var caixaTexto = raiz.querySelector('[data-cine-copies]');
    var botao      = raiz.querySelector('[data-cine-play]');

    var estilo = getComputedStyle(raiz);
    var dur  = emMs(estilo.getPropertyValue('--cine-dur'), 7000);
    var swap = emMs(estilo.getPropertyValue('--cine-swap'), 1200);
    var reduz = reduzido();

    var imgs = [], focaveis = [], i;
    for (i = 0; i < n; i++) {
      imgs.push(slides[i].querySelector('img'));
      focaveis.push(copias[i].querySelectorAll('a[href],button,[tabindex]'));
    }

    /* Qual slide o markup deixou aceso. É o primeiro por convenção, mas ler
       do DOM evita que trocar a ordem no HTML quebre a primeira pintura. */
    var atual = 0;
    for (i = 0; i < n; i++) if (slides[i].classList.contains('is-on')) { atual = i; break; }

    /* ------------------------------------------------------------ ids e ARIA */
    /* Padrão "carousel with tabbed slide picker" do W3C APG. O painel de cada
       aba é a CÓPIA (texto e botão), não a foto: painel tem de ser o que se
       lê e o que se clica. */
    var pre = 'cine-' + (++conta) + '-';
    for (i = 0; i < n; i++) {
      abas[i].id = pre + 't' + i;
      copias[i].id = pre + 'p' + i;
      abas[i].setAttribute('aria-controls', copias[i].id);
      copias[i].setAttribute('aria-labelledby', abas[i].id);
    }

    /* ------------------------------------------------------------ estado */

    function ligarCopia(k) {
      copias[k].classList.add('is-on');
      copias[k].removeAttribute('aria-hidden');
      copias[k].removeAttribute('inert');
      var f = focaveis[k], j;
      for (j = 0; j < f.length; j++) f[j].removeAttribute('tabindex');
    }

    /* Três travas para o mesmo problema, e cada uma cobre o buraco da outra:
       aria-hidden tira da árvore de acessibilidade; inert tira do foco e do
       clique (é recente — Chrome 102, Safari 15.5, Firefox 112); tabindex
       -1 é a rede para quem não tem inert. O visibility:hidden do CSS já
       tiraria da ordem de tabulação sozinho, mas ele só entra DEPOIS da
       fusão (tem delay), e nesse intervalo a cópia que sai continuaria
       alcançável por Tab. */
    function desligarCopia(k) {
      copias[k].classList.remove('is-on');
      copias[k].setAttribute('aria-hidden', 'true');
      copias[k].setAttribute('inert', '');
      var f = focaveis[k], j;
      for (j = 0; j < f.length; j++) f[j].setAttribute('tabindex', '-1');
    }

    function pintarAbas() {
      for (var k = 0; k < n; k++) {
        var ativa = (k === atual);
        if (ativa) abas[k].classList.add('is-on');
        else abas[k].classList.remove('is-on');
        abas[k].setAttribute('aria-selected', ativa ? 'true' : 'false');
        /* Roving tabindex: uma parada de Tab para a barra inteira, e as setas
           andam por dentro dela. É o que o padrão de abas pede. */
        abas[k].setAttribute('tabindex', ativa ? '0' : '-1');
      }
    }

    /* O leitor de tela não deve narrar slide que troca sozinho — viraria
       interrupção a cada 7s. Com o relógio desligado (pausa travada ou
       movimento reduzido) a troca passa a ser sempre pedida por alguém, e aí
       anunciar é o certo. Recomendação do próprio APG. */
    function vivo() {
      if (!caixaTexto) return;
      caixaTexto.setAttribute('aria-live', (reduz || travado) ? 'polite' : 'off');
    }

    /* ------------------------------------------------------------ zoom */

    /* Reinicia a animação de zoom da foto que entra.

       Existe por causa do .is-out: a foto que sai continua com o
       animation-name aplicado durante a fusão (é o que a impede de encolher
       no meio do caminho). Se ela voltar a ser a foto ativa antes de a fusão
       terminar — dois cliques rápidos na barra de abas —, o animation-name
       nunca chegou a mudar, e o navegador NÃO recomeça uma animação que já
       está rodando: a foto reapareceria já ampliada, com o zoom pela metade.

       Medido: sem a leitura de offsetWidth entre as duas escritas, o
       navegador junta as duas no mesmo quadro, o valor computado nunca passa
       por "none" e o reinício não acontece. A leitura força o cálculo de
       layout no meio — é o único jeito de separar as duas escritas sem
       esperar um quadro. Medido: com a leitura, currentTime volta a 0; sem
       ela, fica nos 2629ms em que estava. Custo do layout forçado: 0,016ms
       por troca (média de 200, headless), contra 0,002ms sem ele. */
    function reiniciarZoom(el) {
      if (!el) return;
      el.style.animation = 'none';
      void el.offsetWidth;
      el.style.animation = '';
    }

    /* ------------------------------------------------------------ troca */

    var saindo = -1, tSaida = 0;

    function mostrar(idx) {
      if (idx === atual || idx < 0 || idx >= n) return;
      var ant = atual;
      atual = idx;

      /* A foto que sai ganha .is-out e mantém o zoom correndo enquanto
         esmaece; o CSS explica por quê. A classe cai sozinha depois da
         fusão — os 60ms de folga cobrem o arredondamento do timer. */
      if (tSaida) { clearTimeout(tSaida); tSaida = 0; }
      if (saindo > -1 && saindo !== ant) slides[saindo].classList.remove('is-out');
      slides[ant].classList.remove('is-on');
      slides[ant].classList.add('is-out');
      slides[ant].setAttribute('aria-hidden', 'true');
      saindo = ant;
      tSaida = (function (quem) {
        return setTimeout(function () {
          slides[quem].classList.remove('is-out');
          if (saindo === quem) saindo = -1;
          tSaida = 0;
        }, swap + 60);
      })(ant);

      slides[atual].classList.remove('is-out');
      reiniciarZoom(imgs[atual]);
      slides[atual].classList.add('is-on');
      slides[atual].removeAttribute('aria-hidden');

      desligarCopia(ant);
      ligarCopia(atual);
      pintarAbas();
      reiniciarRelogio();
    }

    /* Índice circular: -1 vira o último, n vira o primeiro. */
    function ir(idx) { mostrar((idx % n + n) % n); }

    /* ------------------------------------------------------------ relógio */

    var timer = 0, t0 = 0, restante = dur;
    var travado = false;                 /* pausa pedida no botão */
    var oculto = !!document.hidden;      /* aba em segundo plano */
    var naTela = true;                   /* dobra dentro da janela */
    var sobPonteiro = false;             /* ponteiro sobre o texto ou as abas */
    var comFoco = false;                 /* foco dentro da seção */

    function podeCorrer() {
      return !reduz && !travado && !oculto && naTela && !sobPonteiro && !comFoco;
    }

    /* Guarda o que FALTA, não quanto já passou: é isso que faz a linha de
       progresso e o timer retomarem juntos. Sem contar o tempo decorrido, um
       hover de meio segundo devolveria 7s cheios ao timer enquanto a barra
       continuaria de onde parou, e as duas se descolariam a cada pausa. */
    function parar() {
      if (timer) {
        clearTimeout(timer);
        timer = 0;
        restante = Math.max(0, restante - (Date.now() - t0));
      }
      raiz.classList.add('is-paused');
    }

    function correr() {
      if (timer || !podeCorrer()) return;
      t0 = Date.now();
      timer = setTimeout(bater, restante);
      raiz.classList.remove('is-paused');
    }

    function reiniciarRelogio() {
      if (timer) { clearTimeout(timer); timer = 0; }
      restante = dur;
      /* Escreve a pausa antes de tentar correr: se não puder (ponteiro em
         cima, por exemplo), a classe fica e segura as animações do slide
         novo no primeiro quadro. Não há pintura entre as duas linhas. */
      raiz.classList.add('is-paused');
      correr();
    }

    function atualizar() { if (podeCorrer()) correr(); else parar(); }

    function bater() {
      timer = 0;
      ir(atual + 1);
    }

    /* ------------------------------------------------------------ eventos */

    for (i = 0; i < n; i++) {
      (function (k) {
        /* Sem roubar o foco: quem clicou já está com o ponteiro ali, e mover
           o foco na hora do clique faria o anel aparecer para quem usa mouse
           em alguns navegadores. Quem chega pelo teclado passa pelo keydown
           lá embaixo, que move o foco de propósito. */
        abas[k].addEventListener('click', function () { ir(k); });
      })(i);
    }

    /* Setas em qualquer lugar da seção; Home/End só quando o foco está numa
       aba, senão roubaríamos de quem só queria ir ao topo da página. */
    raiz.addEventListener('keydown', function (e) {
      var pos = -1, j;
      for (j = 0; j < n; j++) if (abas[j] === e.target) pos = j;

      var alvo;
      if (e.key === 'ArrowRight') alvo = atual + 1;
      else if (e.key === 'ArrowLeft') alvo = atual - 1;
      else if (e.key === 'Home' && pos > -1) alvo = 0;
      else if (e.key === 'End' && pos > -1) alvo = n - 1;
      else return;

      e.preventDefault();
      alvo = (alvo % n + n) % n;
      mostrar(alvo);
      /* O foco vai SEMPRE para a aba do slide novo. Sem isto, quem apertou a
         seta estando no botão do slide anterior ficaria com o foco dentro de
         uma cópia que acabou de sair da árvore, e o navegador o devolveria
         ao body — a pessoa perde o lugar na página. */
      abas[alvo].focus();
    });

    /* A FIAÇÃO DO BOTÃO VEM DO CORE (LIB.pausavel): é ele quem mostra o botão
       — que nasce [hidden] —, guarda o estado, escreve data-pausado (de onde
       o .btn-play tira qual ícone aparece) e leva o rótulo de
       data-rot-pausar / data-rot-tocar para o aria-label. Os dois rótulos
       moravam DENTRO deste arquivo até 2026-09-21: copy cravada em
       JavaScript, onde nenhum redator vai procurar.

       COM MOVIMENTO REDUZIDO NÃO SE CHAMA A FUNÇÃO, e é essa a regra que o
       core documenta: não há troca automática para pausar, e um botão que não
       comanda nada é pior que botão nenhum. Sem a chamada, ele fica [hidden]
       como nasceu — sem precisar que ninguém o esconda.

       A pergunta pelo LIB é a mesma de reduzido(): colado sem o motion.js o
       componente ainda abre, e o botão continua escondido em vez de morto. */
    if (botao && !reduz && window.LIB && window.LIB.pausavel) {
      window.LIB.pausavel(botao, function (pausado) {
        travado = pausado;
        vivo();
        atualizar();
      });
    }

    /* Pausa por ponteiro só onde alguém está lendo ou escolhendo: o bloco de
       texto e a barra de abas. Na seção inteira seria pior do que não ter —
       a dobra ocupa a tela toda e o ponteiro parado em qualquer canto
       congelaria o carrossel para sempre. */
    var sobre = 0;
    function entrou() { sobre++; sobPonteiro = true; atualizar(); }
    function saiu() {
      sobre--;
      if (sobre > 0) return;
      sobre = 0; sobPonteiro = false; atualizar();
    }
    if (caixaAbas) {
      caixaAbas.addEventListener('mouseenter', entrou);
      caixaAbas.addEventListener('mouseleave', saiu);
    }
    if (caixaTexto) {
      caixaTexto.addEventListener('mouseenter', entrou);
      caixaTexto.addEventListener('mouseleave', saiu);
    }

    raiz.addEventListener('focusin', function () { comFoco = true; atualizar(); });
    raiz.addEventListener('focusout', function (e) {
      /* relatedTarget é quem VAI receber o foco: continuar dentro da seção
         não é sair dela. */
      if (e.relatedTarget && raiz.contains(e.relatedTarget)) return;
      comFoco = false;
      atualizar();
    });

    /* Aba em segundo plano: o setTimeout continuaria correndo (o navegador só
       o afrouxa para no mínimo 1s) e a pessoa voltaria dois ou três slides
       adiante, sem ter visto nenhum. Parar e retomar do ponto exato é o que
       faz a volta parecer que nada aconteceu. */
    document.addEventListener('visibilitychange', function () {
      oculto = !!document.hidden;
      atualizar();
    });

    var io = new IntersectionObserver(function (entradas) {
      for (var k = 0; k < entradas.length; k++) naTela = entradas[k].isIntersecting;
      atualizar();
    }, { threshold: 0 });
    io.observe(raiz);

    /* ------------------------------------------------------------ arrasto */
    /* Sem setPointerCapture e sem preventDefault, de propósito:

       - capturar o ponteiro retarget o clique para a seção, e o botão do
         slide deixaria de abrir no toque;
       - preventDefault mataria a rolagem vertical da página, que é o gesto
         mais comum sobre um hero.

       O navegador cuida disso sozinho: num gesto vertical ele começa a rolar
       e dispara pointercancel, que zera o nosso. Num gesto horizontal não há
       o que rolar, os pointermove continuam chegando e o limiar decide. */
    var px = 0, py = 0, arrastando = false, idp = -1;

    raiz.addEventListener('pointerdown', function (e) {
      if (typeof e.button === 'number' && e.button !== 0) return;
      arrastando = true; idp = e.pointerId;
      px = e.clientX; py = e.clientY;
    });
    raiz.addEventListener('pointerup', function (e) {
      if (!arrastando || e.pointerId !== idp) return;
      arrastando = false;
      var dx = e.clientX - px, dy = e.clientY - py;
      /* 40px de limiar e domínio horizontal: sem a segunda condição, uma
         rolagem com o dedo que desviasse 40px para o lado trocaria o slide. */
      if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy)) return;
      ir(dx < 0 ? atual + 1 : atual - 1);
    });
    raiz.addEventListener('pointercancel', function () { arrastando = false; });

    /* ------------------------------------------------------------ abertura */

    /* .is-live antes de qualquer outra coisa e sem esperar quadro nenhum: é
       ele que esconde o texto para a coreografia ter de onde sair, e que faz
       a barra de abas existir. O script mora no fim do <body>, então isto
       roda antes da primeira pintura. */
    raiz.classList.add('is-live');

    for (i = 0; i < n; i++) {
      if (i === atual) ligarCopia(i); else desligarCopia(i);
    }
    pintarAbas();
    vivo();

    /* Dois rAF encadeados garantem que o estado inicial (opacidade zero,
       blur, deslocamento) já pintou antes de a classe ligar a transição —
       com um só, o navegador às vezes junta os dois quadros e o texto
       aparece pronto. Convenção aberta pelo hero/full-bleed-media.

       O relógio parte DAQUI, e não do window.load: esperar as três fotos
       atrasaria a headline no primeiro acesso, e o load pode nunca chegar
       se um recurso qualquer da página travar. O que importa é que a
       primeira troca acontece --cine-dur depois da abertura, e não no
       carregamento. */
    function abrir() {
      if (raiz.classList.contains('in')) return;
      raiz.classList.add('in');
      reiniciarRelogio();
    }
    requestAnimationFrame(function () { requestAnimationFrame(abrir); });
    /* Rede para aba aberta em segundo plano, onde o rAF não roda. */
    setTimeout(abrir, 450);
  }

  for (var r = 0; r < raizes.length; r++) iniciar(raizes[r]);
})();

Abertura de tela cheia com três slides. Cada slide é uma foto sangrada que entra em fusão com desfoque e passa os sete segundos seguintes num zoom lento — o Ken Burns —, com olho, headline que entra palavra a palavra, apoio e botão próprios. No pé da dobra, uma barra de três abas numeradas reparte a largura da coluna: a aba ativa tem uma linha de progresso que enche exatamente no tempo do slide, e é ela que diz quanto falta. Clicar numa aba, apertar as setas do teclado ou arrastar o dedo troca na hora. O relógio para quando o ponteiro entra no texto ou nas abas, quando o foco entra na seção, quando a aba do navegador é escondida, quando a dobra sai da tela e quando alguém aperta o botão de pausa — e retoma do ponto exato em que parou, nunca do começo.

Resolve o hero de quem tem três promessas do mesmo tamanho e não consegue eleger uma. É o caso comum da assessoria: sucessão, internacional e carteira não são etapas de um funil, são três portas — e escolher uma para o topo da home significa esconder duas. A alternativa honesta seria uma lista de três cards, que não abre página nenhuma; o slideshow dá a cada porta uma tela inteira, com foto e botão próprios, e devolve o controle a quem chega pela barra de abas em vez de obrigar a esperar.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Slides Três. Dois lêem como indecisão, e a barra fica com metade da largura por aba. Quatro é o teto — acima disso o rótulo não cabe em 390px.
Olho 2 a 3 palavras, categoria e não benefício. É o rótulo da frente de trabalho: "Planejamento sucessório", não "Cuide do seu futuro".
Headline 5 a 8 palavras, uma frase só, com ponto final. Até ~46 caracteres. Sem <em>: o destaque aqui é a foto.
Apoio 90 a 150 caracteres, duas linhas em desktop. Diz o que está incluído, não o que é bom.
Botão Um por slide, verbo no imperativo ou infinitivo, 2 a 4 palavras, destinos diferentes entre os slides. Se os três levam ao mesmo lugar, é um hero só com três fotos.
Aba — número Dois dígitos, sequência simples (01, 02, 03). É pontuação, não dado.
Aba — rótulo Uma palavra, até 14 caracteres, caixa baixa no HTML (a caixa alta é do CSS). Duas palavras quebram em duas linhas em 390px — funciona, mas desalinha o ritmo da régua.

O que mantém a dobra coesa é o paralelismo entre os três slides: mesma quantidade de palavras na headline (±2), apoio do mesmo tamanho, botões com a mesma forma verbal. Como as três cópias ocupam a mesma célula do grid e são alinhadas pela base, uma headline de três linhas ao lado de duas de uma linha faz o bloco de texto pular de altura visualmente a cada troca — o botão fica parado, o título é que sobe.

A onda de palavras é o que dá o ritmo, e ela tem prazo: 8 palavras × 54ms + 0,9s de transição = 1,4s até a frase inteira estar em pé. Com 20 palavras seriam 2,0s de um slide de 7s só montando a frase.

Especificação dos assets


Como usar

<link rel="stylesheet" href="componentes/hero/slideshow-cinematico/estilo.css">
<script src="componentes/hero/slideshow-cinematico/script.js"></script>

Cole o markup.html como primeiro filho do <body>, troque as três fotos, os três --cine-pos e o texto. Precisa do core/tokens.css, do core/base.css e — desde 2026-09-21 — do core/motion.js: é dele que vem o LIB.pausavel, a fiação do botão de pausa. Sem ele a dobra ainda abre e troca sozinha (o script lê LIB.reduce quando existe e cai no matchMedia quando não), mas o botão fica [hidden], e é ele que a WCAG 2.2.2 exige.

Ganchos

Gancho Papel
[data-cine] Raiz. O script parte daqui, nunca do documento — a dobra pode se repetir na página.
[data-cine-slide] A foto de um slide.
[data-cine-copy] O bloco de texto de um slide — é o role="tabpanel" da aba de mesmo índice.
[data-cine-copies] O empilhamento das cópias: pausa no hover e carrega o aria-live.
[data-cine-tabs] O role="tablist". Pausa no hover.
[data-cine-tab] Cada aba.
[data-cine-play] O botão de pausa, vestindo a primitiva .btn-play. Nasce [hidden]; quem o mostra é o LIB.pausavel do core — e sob movimento reduzido o script simplesmente não o chama.
data-rot-pausar / data-rot-tocar Os dois rótulos do botão, no atributo. Nomes do core — é ele quem lê e escreve o aria-label.
--cine-dur O relógio da dobra. Tempo do slide, duração do zoom e da linha de progresso — os três lêem esta variável, e o script a lê do CSS. 7s no padrão.
--cine-swap Fusão entre uma foto e a próxima. 1.2s.
--cine-fade Fusão do bloco de texto. .45s — mais curta que a da foto de propósito.
--cine-blur Desfoque da fusão. 16px no desktop, 8px abaixo de 760px.
--cine-zoom Escala no fim do Ken Burns. 1.08. 1 desliga.
--cine-word-base / --cine-word-lag Atraso da primeira palavra (.1s) e passo entre palavras (54ms).
--cine-title Corpo da headline. clamp(34px,5vw,72px).
--cine-pos object-position da foto, inline em cada <img>.
--cine-i Índice da palavra, inline em cada .cine-w. É o que escalona a onda.
.is-live Na raiz: o script está rodando. Faz a barra de abas existir e esconde o texto para a coreografia ter de onde sair.
.in Na raiz: abertura. Dispara a coreografia, o zoom e a linha de progresso, e marca o instante zero do relógio.
.is-on No slide, na cópia e na aba de mesmo índice.
.is-out No slide que sai — mantém o zoom correndo durante a fusão.
.is-paused Na raiz: relógio parado. Congela o zoom e a linha de progresso.
[data-pausado] No próprio botão, escrito pelo core. É de onde o .btn-play tira qual ícone aparece. Substituiu a classe .is-off.

Variações


Gotchas


Histórico

Projeto Onde Data Diferenças
autoral construído do zero na biblioteca 2026-09-03 —
migração fiação do botão de pausa promovida ao core 2026-09-21 Saíram deste componente a troca de ícone por .is-off, o [hidden] desligado à mão, o tamanho do ícone e — o que mais importava — os dois rótulos, que eram copy cravada dentro do script.js. Entraram .btn-play (core/base.css), LIB.pausavel (core/motion.js) e data-rot-pausar / data-rot-tocar no markup. Por quê: este componente e o hero/video-fundo faziam a mesma fiação cada um do seu jeito, e a leva de etapas, filme e depoimentos em rodízio traria mais três cópias — o §6 manda promover na terceira. O disco continua sendo .cine-play e não vira .btn-disc: aqui a borda e a tinta são medidas contra a foto, em --light, e o --rule-2 do escopo sumiria sobre a imagem

Origem. Componente autoral (contrato §11): não é clone de uma referência, é a tradução de um padrão de interação que aparece em muito site premium — o herói rotativo das plataformas de streaming, com a fileira de abas e a barra que enche; as páginas de produto da Apple e da Nike, com foto de tela cheia em zoom lento; os templates de hero premiados no Awwwards e vendidos em Framer e Webflow. Não há URL de referência e não há uma linha copiada: o arquivo nasceu aqui, em token, prefixo, primitiva do core e padrão ARIA. Status externo: nunca rodou em produção nossa. O primeiro site que usar promove, e esta ficha registra qual foi. O registro consolidado vai em projetos/autorais.md.

Decisões de tradução para o contrato: