← Biblioteca componentes/listas/linha-do-tempo-etapas/ficha.md CódigoTela cheia
listas/linha-do-tempo-etapas

Linha do tempo de etapas

🟢 estavel complexidade média prefixo etp- processo etapas linha do tempo scroll vertical como funciona metodo onboarding prazos

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

<link rel="stylesheet" href="componentes/listas/linha-do-tempo-etapas/estilo.css">
<script src="componentes/listas/linha-do-tempo-etapas/script.js"></script>

markup.html · 153 linhas · 9,6 KB

<!-- ==========================================================================
     LINHA DO TEMPO DE ETAPAS — filete vertical que se preenche com a rolagem
     Prefixo: etp-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Usa as primitivas .wrap, .sec-pad, .eyebrow, .sec-title, .sec-lead,
     .pill e .visually-hidden do core. Sem lib.

     A IDEIA. Cinco etapas empilhadas numa coluna só: filete à esquerda,
     conteúdo à direita. O filete é a peça gráfica — um trilho fraco em
     --rule e, por cima, um preenchimento em --accent que cresce até o
     marcador da etapa que está sendo lida. Quem lê vê onde está no processo
     pelo próprio ato de rolar, sem barra, sem número de progresso e sem
     nada para clicar.

     COLUNA ÚNICA, E NÃO ALTERNADA. Linha do tempo com blocos revezando
     esquerda/direita fica bonita em 1440 e vira remendo em 390, onde os dois
     lados desabam para a mesma coluna e a diagonal do olho deixa de existir.
     Processo se lê em sequência, não em simetria.

     O TRILHO É POR ETAPA, e é isso que faz a geometria fechar sem uma linha
     de JavaScript. Cada .etp-item carrega o próprio segmento (.etp-fio), que
     vai do centro do marcador dele até o centro do marcador do seguinte —
     ver a conta no estilo.css. O último item não desenha segmento, e é por
     isso que o filete NUNCA passa do último marcador: não existe filete
     depois dele para passar.

     O QUE O SCRIPT FAZ: escreve UM número na seção (--etp-k, o progresso em
     unidades de etapa) e marca qual etapa é a corrente. Cada segmento
     calcula sozinho, em CSS, a própria fatia desse número. Ver script.js.

     data-rv NO INVÓLUCRO DO TEXTO, nunca no <li>. O preenchimento se move
     por transform (scaleY), e o core termina todo reveal em
     [data-rv].in{transform:none} — seletor (0,2,0), que vence qualquer
     classe do componente. No mesmo nó, o transform do preenchimento seria
     apagado em silêncio assim que a dobra entrasse. Aqui o data-rv mora em
     .etp-corpo, que é IRMÃO do filete: nem no mesmo nó, nem no caminho dele.
     Um segundo motivo, tão importante quanto: [data-rv] em repouso vale
     translateY(22px), e o script mede a posição dos marcadores — marcador
     dentro de um bloco deslocado devolveria medida errada até o reveal.

     PRAZOS ILUSTRATIVOS. Os intervalos de calendário abaixo são exemplo
     editorial, calibrados para soarem plausíveis numa assessoria — não são
     compromisso contratual de ninguém. Trocar pelos prazos reais do cliente
     antes de publicar, e datá-los. Nenhuma etapa fala de retorno,
     rentabilidade ou resultado, e isso é regra, não estilo: ver a seção
     Compliance da ficha.
     ========================================================================== -->

<section class="etp sec-deep sec-pad" data-etp>
  <div class="wrap">

    <header class="etp-head">
      <span class="eyebrow" data-rv>Como funciona</span>
      <h2 class="sec-title ttl">Do diagnóstico ao <em>comitê</em>, em cinco etapas</h2>
      <p class="sec-lead" data-rv data-d="2">O caminho é o mesmo para toda família que atendemos, e você sabe em que ponto dele está. Os prazos ao lado de cada etapa são estimativas de calendário e variam com a complexidade do patrimônio.</p>
    </header>

    <!-- role="list" DE VOLTA, e nao e' redundante: o reset `ul,ol{list-style:none}`
         do core custa a semantica de lista no Safari com VoiceOver, que deixa de
         anunciar "lista de 5 itens" quando o marcador e' removido. Sem isto, e com
         o .etp-num em aria-hidden, a ORDEM das etapas — que e' a informacao desta
         peca — nao chegava a quem ouve a pagina. A casa tem duas saidas para o
         mesmo problema: esta, e o numero audivel do conteudo/rolagem-sincronizada.
         Escolher uma e' obrigatorio. -->
    <ol class="etp-lista" role="list">

      <!-- ------------------------------------------------------------ 01 -->
      <li class="etp-item" style="--etp-i:0">
        <span class="etp-fio" aria-hidden="true"><span class="etp-fio-fill"></span></span>
        <span class="etp-marca" aria-hidden="true"><span class="etp-ponto"></span></span>
        <div class="etp-corpo" data-rv>
          <div class="etp-topo">
            <span class="etp-num" aria-hidden="true">01</span>
            <span class="pill etp-prazo"><span class="visually-hidden">Prazo estimado: </span>de 2 a 3 semanas</span>
          </div>
          <h3 class="etp-titulo">Diagnóstico patrimonial</h3>
          <p class="etp-texto">Levantamos o que existe hoje: patrimônio consolidado nas instituições em que ele já está, estrutura familiar, compromissos de liquidez e o que foi contratado antes de nós. Nada é recomendado nesta etapa — o objetivo é enxergar o conjunto de uma vez.</p>
        </div>
      </li>

      <!-- ------------------------------------------------------------ 02 -->
      <li class="etp-item" style="--etp-i:1">
        <span class="etp-fio" aria-hidden="true"><span class="etp-fio-fill"></span></span>
        <span class="etp-marca" aria-hidden="true"><span class="etp-ponto"></span></span>
        <div class="etp-corpo" data-rv>
          <div class="etp-topo">
            <span class="etp-num" aria-hidden="true">02</span>
            <span class="pill etp-prazo"><span class="visually-hidden">Prazo estimado: </span>de 2 a 4 semanas</span>
          </div>
          <h3 class="etp-titulo">Política de investimentos</h3>
          <p class="etp-texto">Um documento escrito fixa faixas de alocação por classe, limites de risco, a liquidez que a família precisa manter disponível e o horizonte de cada objetivo. É ele que orienta as decisões seguintes — as nossas e as suas.</p>
        </div>
      </li>

      <!-- ------------------------------------------------------------ 03 -->
      <li class="etp-item" style="--etp-i:2">
        <span class="etp-fio" aria-hidden="true"><span class="etp-fio-fill"></span></span>
        <span class="etp-marca" aria-hidden="true"><span class="etp-ponto"></span></span>
        <div class="etp-corpo" data-rv>
          <div class="etp-topo">
            <span class="etp-num" aria-hidden="true">03</span>
            <span class="pill etp-prazo"><span class="visually-hidden">Prazo estimado: </span>de 1 a 2 semanas</span>
          </div>
          <h3 class="etp-titulo">Implementação da alocação</h3>
          <p class="etp-texto">A carteira é montada dentro das faixas aprovadas, na custódia que já é sua. Cada movimentação passa por você antes de acontecer, e a remuneração do assessor é a taxa previamente acordada — não a operação executada.</p>
        </div>
      </li>

      <!-- ------------------------------------------------------------ 04 -->
      <li class="etp-item" style="--etp-i:3">
        <span class="etp-fio" aria-hidden="true"><span class="etp-fio-fill"></span></span>
        <span class="etp-marca" aria-hidden="true"><span class="etp-ponto"></span></span>
        <div class="etp-corpo" data-rv>
          <div class="etp-topo">
            <span class="etp-num" aria-hidden="true">04</span>
            <span class="pill etp-prazo"><span class="visually-hidden">Prazo estimado: </span>relatório mensal</span>
          </div>
          <h3 class="etp-titulo">Acompanhamento</h3>
          <p class="etp-texto">Relatório consolidado de todas as instituições, no mesmo formato todo mês, para que a comparação entre um mês e outro seja leitura e não trabalho. Entre um relatório e o seguinte, o assessor continua acessível: mudança de vida raramente espera a data do comitê.</p>
        </div>
      </li>

      <!-- ------------------------------------------------------------ 05
           A ÚNICA ETAPA COM IMAGEM, e de propósito: a foto aqui é opcional e
           serve para dar peso ao fecho, não para ilustrar cada linha. Cinco
           fotos empilhadas transformariam a coluna num carrossel vertical e
           afogariam o filete, que é a peça gráfica da dobra.
           A legenda vai DENTRO da imagem, ancorada embaixo (contrato §6) —
           embaixo da foto ela viraria uma segunda peça. -->
      <li class="etp-item" style="--etp-i:4">
        <span class="etp-fio" aria-hidden="true"><span class="etp-fio-fill"></span></span>
        <span class="etp-marca" aria-hidden="true"><span class="etp-ponto"></span></span>
        <div class="etp-corpo" data-rv>
          <div class="etp-topo">
            <span class="etp-num" aria-hidden="true">05</span>
            <span class="pill etp-prazo"><span class="visually-hidden">Prazo estimado: </span>a cada seis meses</span>
          </div>
          <h3 class="etp-titulo">Revisão em comitê</h3>
          <p class="etp-texto">A política volta à mesa: o que mudou na família, na empresa e no cenário, e o que disso pede ajuste de faixa. A revisão sai de lá registrada — decisão datada, com o motivo escrito ao lado, e não conversa solta.</p>
          <figure class="etp-figura">
            <img src="../../../showroom/demo/ph-wide-4.webp" alt="Sala de reunião vazia com vista para a cidade" loading="lazy" decoding="async" width="1600" height="900">
            <figcaption class="etp-legenda">O comitê reúne assessor, especialista de alocação e a família — a ata fica com você.</figcaption>
          </figure>
        </div>
      </li>

    </ol>

    <!-- A ressalva mora ONDE A PESSOA LÊ O NÚMERO, não no rodapé da página. -->
    <p class="nota etp-nota" data-rv>Prazos estimados, contados a partir da assinatura do contrato de assessoria. Variam com a quantidade de instituições envolvidas e com a estrutura societária da família.</p>

  </div>
</section>

estilo.css · 339 linhas · 13,4 KB

/* ==========================================================================
   LINHA DO TEMPO DE ETAPAS — prefixo etp-
   Coluna única: filete à esquerda, conteúdo à direita. O filete tem um trilho
   fraco e, por cima, um preenchimento na cor de marca que cresce com a
   rolagem até o marcador da etapa que está sendo lida.

   Tokens usados:
     --accent           preenchimento, marcador aceso, halo da etapa corrente
     --display          face do número e do título de etapa
     --sans             face do texto e da legenda
     --ease             curva do projeto
     --dur              não usado direto — os tempos aqui são curtos de propósito
     --fg --fg-2        título da etapa e corpo do texto
     --fg-muted         número em repouso e nota de ressalva
     --rule --rule-2    trilho do filete e contorno do marcador apagado
     --bg               miolo do marcador apagado (o filete passa por trás)
     --dark-rgb         véu em gradiente da legenda dentro da foto
     --light            tinta da legenda, que vive sobre a foto
     --radius-media     canto da foto
     --radius-pill      só via .pill do core

   ---------------------------------------------------------------------
   A GEOMETRIA DO FILETE, que é o miolo do componente.

   Cada .etp-item desenha o PRÓPRIO segmento, e nenhuma medida vem do
   JavaScript. A conta fecha assim:

     - o marcador da etapa fica a --etp-cap do topo do item (metade da
       altura da faixa superior, onde moram o número e o prazo);
     - o item termina com --etp-vao de respiro por baixo;
     - logo, do centro do marcador i até o centro do marcador i+1 há
       exatamente a altura do item i.

   Daí o segmento ser `inset-block: var(--etp-cap) calc(var(--etp-cap) * -1)`:
   começa no centro do próprio marcador e VAZA --etp-cap para baixo da caixa
   do item, que é onde o marcador seguinte começa. Altura resultante: a do
   item, ponto — sem medir nada.

   O último item não desenha segmento. É por isso que o preenchimento nunca
   passa do último marcador: não há trilho depois dele.

   ---------------------------------------------------------------------
   DOIS ESTADOS DE REPOUSO, e o de repouso é o CHEIO.

   Sem JavaScript (bloqueado, com erro, ainda não executado) e com "reduzir
   movimento" ligado, a seção fica sem a classe .is-live: o preenchimento
   nasce em scaleY(1) e todos os marcadores nascem acesos. A informação —
   cinco etapas, nesta ordem, com estes prazos — está inteira.

   Quem LIGA o comportamento é o script, pondo .is-live na seção. Só então o
   preenchimento passa a ler --etp-k e os marcadores ainda não lidos apagam.
   O contrário (nascer vazio e o script encher) deixaria a peça em branco no
   dia em que o script não rodar, e um processo sem filete não é um processo
   com menos animação: é uma lista de cinco parágrafos soltos.
   ========================================================================== */

.etp{
  /* Largura da coluna do filete. É ela que decide o recuo do texto — o
     conteúdo começa em --etp-col + --etp-gap-col a partir da coluna. */
  --etp-col:clamp(30px,4vw,44px);
  --etp-gap-col:clamp(16px,2.2vw,28px);

  /* Respiro entre uma etapa e a seguinte. Entra como padding-bottom do item,
     e não como gap da lista, porque o segmento mede 100% do ITEM: com gap, o
     vão ficaria fora da caixa e o filete nasceria picotado. */
  --etp-vao:clamp(38px,5.2vh,68px);

  /* Largura de leitura. Menor que a coluna de propósito: parágrafo de
     processo passa de 70ch e vira documento. */
  --etp-max:760px;

  --etp-fio-w:2px;
  --etp-ponto-d:11px;

  /* Altura da faixa superior do conteúdo (número + prazo). O marcador é
     centrado nela, e --etp-cap é a metade — a distância do topo do item ao
     centro do marcador, que é a medida de que toda a geometria depende.
     30px é a altura natural da .pill do core (11px de corpo, entrelinha do
     body, 5px de padding e 1px de borda de cada lado). Trocar a .pill por
     outro rótulo pede refazer este número, senão o marcador sai do eixo do
     número. */
  --etp-topo-h:30px;
  --etp-cap:calc(var(--etp-topo-h) / 2);

  /* A LINHA DE LEITURA: fração da altura da janela em que o filete "chega".
     .5 é o meio da tela, que é o padrão que funciona — o olho descansa ali e
     a etapa corrente troca quando o marcador cruza esse ponto.
     O script LÊ este valor daqui; é o único lugar em que ele existe. Baixar
     para .62 antecipa o acendimento (útil quando a dobra é a última da
     página); subir para .40 atrasa. */
  --etp-linha:.5;

  /* Suavização do preenchimento entre um valor e o seguinte. A rolagem
     chega em degraus (entalhe de roda, gesto de trackpad); sem isto o
     filete pula a cada degrau. Curto o bastante para não virar atraso. */
  --etp-suave:120ms;
}

.etp-head{max-width:var(--etp-max)}
.etp-head .sec-lead{max-width:none}

.etp-lista{
  /* Lista ORDENADA de propósito: a ordem das etapas é a informação, e o
     número visível é a .etp-num, não o ::marker. O reset de `ul,ol` do core
     é que segura isso — foi este componente que o motivou (o ::marker num
     item `display:grid` entra na grade e empurra o texto). */
  max-width:var(--etp-max);
  /* Respiro entre o cabeçalho e a primeira etapa. Derivado do ritmo da
     seção, nunca clamp solto. */
  margin:calc(var(--sec-gap) * .42) 0 0;
}

.etp-item{
  position:relative;
  display:grid;
  grid-template-columns:var(--etp-col) 1fr;
  column-gap:var(--etp-gap-col);
  align-items:start;
  padding-bottom:var(--etp-vao);
}
/* Sem etapa seguinte não há vão nem segmento a desenhar. As duas regras
   andam juntas: o vão sobrando esticaria a seção, e o segmento sobrando
   faria o filete passar do último marcador — que é exatamente o defeito que
   a forma existe para não ter. */
.etp-item:last-child{padding-bottom:0}
.etp-item:last-child .etp-fio{display:none}

/* ---- o trilho ----
   Filho absoluto do item, e por isso FORA do grid de duas colunas: o
   posicionamento absoluto tira o elemento do fluxo, e as colunas continuam
   valendo só para o marcador e o corpo. */
.etp-fio{
  position:absolute;
  inset-block:var(--etp-cap) calc(var(--etp-cap) * -1);
  inset-inline-start:calc((var(--etp-col) - var(--etp-fio-w)) / 2);
  width:var(--etp-fio-w);
  background:var(--rule);
  /* Abaixo do marcador: o miolo opaco do ponto é o que dá o corte limpo do
     filete em cada etapa. */
  z-index:0;
}

.etp-fio-fill{
  display:block;
  width:100%;
  height:100%;
  background:var(--accent);
  /* scaleY e não height: transform e opacidade são as duas únicas
     propriedades que o navegador anima sem refazer layout. Com height, cada
     quadro de rolagem remontaria a caixa de cinco segmentos. A deformação
     que se teme ao escalar não acontece porque a largura é FIXA e só o eixo
     Y escala — um filete de 2px continua com 2px em qualquer escala. */
  transform-origin:top center;
  transform:scaleY(1);
}

/* Com o script no ar, cada segmento tira a própria fatia de UM número.
   --etp-k é o progresso da dobra em unidades de etapa (0 = primeiro
   marcador, 4 = quinto), escrito uma vez por quadro na seção; --etp-i é o
   índice do item, escrito à mão no markup. A subtração dá o quanto ESTE
   segmento já andou, e o clamp corta em 0 e 1: segmentos acima do progresso
   ficam cheios, o segmento corrente fica parcial, os abaixo ficam vazios.
   É o que dispensa o script de tocar em cinco elementos por quadro. */
.etp.is-live .etp-fio-fill{
  transform:scaleY(clamp(0,calc(var(--etp-k,0) - var(--etp-i,0)),1));
  transition:transform var(--etp-suave) linear;
}
@media (prefers-reduced-motion:reduce){
  /* Não há .is-live sob movimento reduzido (o script sai antes), então esta
     regra só alcança o caso de alguém colar o componente sem o motion.js e
     ligar a preferência depois. Fica pela mesma razão que o
     navegacao/progresso-leitura mantém a dele: a POSIÇÃO é dado, o que sai
     é só a suavização entre um valor e o seguinte. */
  .etp.is-live .etp-fio-fill{transition:none}
}

/* ---- o marcador ---- */
.etp-marca{
  position:relative;
  z-index:1;
  display:flex;
  align-items:center;
  justify-content:center;
  /* Altura fixa, e não stretch: o ponto tem de ficar no eixo do número, não
     no meio de um item que pode ter três linhas ou uma foto. Se a faixa
     superior quebrar em duas linhas no celular, o ponto continua centrado na
     PRIMEIRA — que é onde o olho procura. */
  height:var(--etp-topo-h);
}

.etp-ponto{
  width:var(--etp-ponto-d);
  height:var(--etp-ponto-d);
  border-radius:var(--radius-pill);
  /* Repouso = ACESO. O apagado é estado do script (.is-live), não o
     contrário — ver o cabeçalho deste arquivo. */
  background:var(--accent);
  border:1px solid var(--accent);
  transition:background .4s var(--ease),border-color .4s var(--ease),
             box-shadow .4s var(--ease),transform .4s var(--ease);
}

/* Com o script no ar, o que ainda não foi lido apaga. O miolo é --bg (o
   fundo da própria seção) e não transparente: opaco, ele corta o filete que
   passa por trás; transparente, o trilho atravessaria o ponto e o marcador
   viraria um anel com uma linha no meio. */
.etp.is-live .etp-item:not(.is-vista) .etp-ponto{
  background:var(--bg);
  border-color:var(--rule-2);
}

/* A etapa corrente ganha halo. Uma por vez — o critério mora no script. */
.etp.is-live .etp-item.is-atual .etp-ponto{
  transform:scale(1.16);
  box-shadow:0 0 0 5px color-mix(in srgb,var(--accent) 16%,transparent);
}

/* ---- o conteúdo ----
   Este é o nó que leva o data-rv, e é irmão do filete — nunca ancestral
   dele. O core termina o reveal em [data-rv].in{transform:none}, seletor
   (0,2,0), que apagaria o scaleY do preenchimento se os dois morassem no
   mesmo nó. Além disso o repouso do data-rv é translateY(22px): com o
   marcador aqui dentro, a medida que o script faz sairia 22px deslocada até
   a dobra entrar. */
.etp-corpo{min-width:0}

.etp-topo{
  display:flex;
  align-items:center;
  gap:14px;
  min-height:var(--etp-topo-h);
}

.etp-num{
  font-family:var(--display);
  font-weight:700;
  font-size:13px;
  letter-spacing:.1em;
  color:var(--fg-muted);
  transition:color .4s var(--ease);
}
.etp.is-live .etp-item.is-atual .etp-num{color:var(--accent)}

/* Só o rótulo de prazo, sobre a .pill do core. Nada de cor nem de forma
   aqui: o prazo é informação de apoio e tem de continuar lendo como
   rótulo, não como selo de destaque. */
.etp-prazo{white-space:nowrap}

.etp-titulo{
  font-family:var(--display);
  font-weight:700;
  letter-spacing:-.018em;
  font-size:clamp(20px,2.2vw,27px);
  line-height:1.24;
  color:var(--fg);
  margin-top:12px;
}

.etp-texto{
  color:var(--fg-2);
  max-width:62ch;
  margin-top:12px;
}

/* ---- foto opcional dentro de uma etapa ---- */
.etp-figura{
  position:relative;
  overflow:hidden;
  margin:22px 0 0;
  border-radius:var(--radius-media);
  /* A caixa é reservada pela proporção, e não pela imagem que chega: sem
     isto a lista cresce quando a foto pinta, e as posições que o script
     mediu passam a apontar para o lugar errado. */
  aspect-ratio:16 / 9;
  max-width:520px;
}
.etp-figura img{width:100%;height:100%;object-fit:cover}

/* Legenda DENTRO da imagem, ancorada embaixo (contrato §6). O véu é
   gradiente + blur + máscara em gradiente: backdrop-filter em caixa reta
   produz borda dura e lê "chapado", e a máscara é o que dissolve o topo do
   véu na foto. */
.etp-legenda{
  position:absolute;
  inset-inline:0;
  inset-block-end:0;
  padding:34px 18px 14px;
  font-size:12.5px;
  line-height:1.45;
  letter-spacing:.02em;
  color:var(--light);
  background:linear-gradient(to top,
             rgba(var(--dark-rgb),.88) 0%,
             rgba(var(--dark-rgb),.44) 52%,
             rgba(var(--dark-rgb),0) 100%);
  backdrop-filter:blur(7px);
  -webkit-backdrop-filter:blur(7px);
  mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
  -webkit-mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
}

/* Corpo, entrelinha e tinta vêm da primitiva .nota do core; o resto é posição.
   A medida entra pelo parafuso porque é a MESMA largura das etapas. */
.etp-nota{
  --nota-max:var(--etp-max);
  margin-top:clamp(28px,3.4vw,44px);
  /* Alinha com o texto das etapas, não com o filete: a ressalva é do
     conteúdo, não da linha do tempo. */
  padding-inline-start:calc(var(--etp-col) + var(--etp-gap-col));
  border-top:1px solid var(--rule);
  padding-top:18px;
}

/* ==========================================================================
   MOBILE — no fim do bloco, como manda o §6.5.
   A forma NÃO muda: coluna única, filete à esquerda, conteúdo à direita.
   O que muda é a escala — coluna do filete mais estreita e vão menor. Foi
   para poder encolher sem remendo que a alternância esquerda/direita ficou
   de fora desde o começo.
   ========================================================================== */
@media (max-width:640px){
  .etp{
    --etp-col:26px;
    --etp-gap-col:14px;
    --etp-vao:36px;
    --etp-ponto-d:10px;
  }
  .etp-topo{
    /* Número e prazo lado a lado passam de 60% da largura útil em 390px e
       empurram a pílula para fora. Quebrar é melhor que espremer — e o
       marcador continua no eixo da primeira linha, porque .etp-marca tem
       altura fixa. */
    flex-wrap:wrap;
    gap:10px;
  }
  .etp-prazo{white-space:normal}
  .etp-figura{max-width:none}
  .etp-nota{padding-inline-start:0}
}

script.js · 214 linhas · 9,1 KB

/* ==========================================================================
   LINHA DO TEMPO DE ETAPAS — prefixo etp-
   Lê a posição dos marcadores e escreve UM número na seção. Só isso.

   O QUE ESTE SCRIPT FAZ, EM ORDEM:
     1. põe .is-live na seção — é essa classe que liga, no CSS, o
        preenchimento dirigido e o apagamento dos marcadores não lidos. Sem
        ela (sem script, ou com movimento reduzido) o filete nasce cheio e
        todos os marcadores acesos, que é o estado de repouso do estilo.css;
     2. mede, uma vez, a posição de documento de cada marcador;
     3. a cada quadro de rolagem escreve --etp-k, o progresso em UNIDADES DE
        ETAPA (0 = primeiro marcador, 4 = quinto). Cada segmento tira a
        própria fatia desse número em CSS — o script não toca em nenhum
        elemento de filete;
     4. marca a etapa corrente com .is-atual e as já lidas com .is-vista, e
        só quando o índice muda.

   MOVIMENTO REDUZIDO: sai antes de qualquer coisa. Não é o caso do
   navegacao/progresso-leitura, em que a posição na página é dado e continua
   sendo informada — aqui a informação (cinco etapas, nesta ordem, com estes
   prazos) já está inteira no markup, e o filete cheio é a leitura correta de
   um processo visto por quem não quer nada se mexendo. Desligar deixando o
   filete VAZIO seria apagar a peça, não pausá-la.

   NADA É GRAVADO e nada é enviado: a peça não tem campo, não tem clique e
   não guarda estado entre cargas.

   ---------------------------------------------------------------------
   O CRITÉRIO DA ETAPA CORRENTE, e por que ele não é só "cruzou o meio".

   O padrão é a linha média da janela: corrente é a última etapa cujo
   marcador já passou por ela. É o ponto em que o olho descansa, e é o que
   faz a troca parecer resposta à leitura em vez de resposta ao pixel.

   Só que "passou pelo meio" não fecha nas duas pontas do documento. Se a
   dobra é a ÚLTIMA da página, o marcador final pode ficar para sempre na
   metade de baixo da tela: a linha nunca o alcança, o filete nunca completa
   e a peça mente sobre o próprio processo. Simétrico no topo: dobra colada
   ao começo da página nasce com marcadores acima da linha sem que ninguém
   tenha rolado nada.

   A saída é converter cada marcador na ROLAGEM que o traria até a linha
   (s = y - janela * fração) e GRAMPEAR esse número no curso que a página
   realmente tem (0 até scrollHeight - innerHeight). O grampo preserva a
   ordem, então a sequência continua crescente e o progresso continua
   monótono; e no fim do documento o último marcador grampeia no fim do
   curso, o que garante que o filete fecha exatamente ali. Quando sobra
   página depois da dobra — o caso normal —, nenhum grampo age e o critério
   é literalmente "cruzou o meio da janela".
   ========================================================================== */

(function () {

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

  /* A flag do core é a fonte. O fallback existe para o componente colado
     sozinho, sem o 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;
  }

  /* Preso ao scroll da PÁGINA: assina o laço único do core, que já roda em
     requestAnimationFrame e já dispara no resize. O fallback repete a
     semântica — 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();
  }

  function rolagem() {
    return window.pageYOffset || document.documentElement.scrollTop || 0;
  }

  function iniciar(raiz) {

    /* Busca a partir da RAIZ, não do documento: a dobra pode repetir na
       mesma página (um processo de investimento e um de sucessão). */
    var itens = raiz.querySelectorAll('.etp-item');
    /* Menos de duas etapas não têm trilho entre marcadores, e uma linha do
       tempo de um item é um parágrafo. Sai em silêncio e deixa o repouso do
       CSS valendo. */
    if (itens.length < 2) return;

    var marcas = [], i;
    for (i = 0; i < itens.length; i++) {
      var ponto = itens[i].querySelector('.etp-ponto');
      if (!ponto) return;                           // markup incompleto: repouso
      marcas.push(ponto);
    }

    raiz.classList.add('is-live');

    var n      = marcas.length;
    var passos = [];      /* rolagem em que cada marcador chega à linha */
    var indice = -1;      /* etapa corrente; -1 = nenhuma ainda */
    var kEsc   = '';      /* último --etp-k escrito */
    var larg = 0, alt = 0;

    /* ------------------------------------------------------------ medida */
    /* Tudo aqui é leitura de layout, e só roda dentro do rAF do laço —
       nunca dentro de um listener de scroll. */

    function medir() {
      larg = innerWidth; alt = innerHeight;

      /* A fração da linha de leitura mora no CSS (--etp-linha) e é lida
         daqui. Uma fonte só: quem ajustar o componente ajusta um lugar. */
      var fracao = parseFloat(getComputedStyle(raiz).getPropertyValue('--etp-linha'));
      if (!(fracao >= 0 && fracao <= 1)) fracao = .5;

      var doc   = document.documentElement;
      var curso = doc.scrollHeight - alt;
      if (curso < 0) curso = 0;

      var sy = rolagem(), r, s, k;
      passos.length = 0;
      for (k = 0; k < n; k++) {
        r = marcas[k].getBoundingClientRect();
        /* Centro do marcador em coordenada de DOCUMENTO, menos o quanto da
           janela fica acima da linha: sobra a rolagem que traz este
           marcador até ela. */
        s = sy + r.top + r.height / 2 - alt * fracao;
        if (s < 0) s = 0;
        if (s > curso) s = curso;
        passos.push(s);
      }
    }

    /* ------------------------------------------------------------ quadro */

    function passo() {
      var sy = rolagem(), k, alvo, vao;

      /* Índice fracionário sobre a sequência grampeada. Ela é crescente (o
         grampo preserva ordem), então basta caminhar. */
      if (sy <= passos[0]) {
        alvo = 0;
      } else if (sy >= passos[n - 1]) {
        alvo = n - 1;
      } else {
        k = 0;
        while (k < n - 2 && sy >= passos[k + 1]) k++;
        vao = passos[k + 1] - passos[k];
        /* Dois marcadores grampeados no mesmo ponto (dobra maior que o
           curso da página) dariam divisão por zero — e NaN em --etp-k faz o
           CSS descartar a declaração inteira, deixando o filete no valor
           anterior para sempre. Vão nulo significa que os dois chegam
           juntos: o segmento fecha de uma vez. */
        alvo = vao > 0 ? k + (sy - passos[k]) / vao : k + 1;
      }

      var texto = alvo.toFixed(4);
      if (texto !== kEsc) {
        kEsc = texto;
        raiz.style.setProperty('--etp-k', texto);
      }

      /* Corrente = a última etapa cujo marcador já cruzou a linha. Antes da
         primeira cruzar não há corrente nenhuma, e isso é honesto: a dobra
         ainda está entrando, ninguém está lendo etapa nenhuma. */
      var atual = -1;
      for (k = 0; k < n; k++) if (sy >= passos[k]) atual = k;

      /* Escreve só quando muda: mexer em classe de cinco elementos a cada
         quadro de rolagem é trabalho de estilo repetido à toa. */
      if (atual === indice) return;
      indice = atual;
      for (k = 0; k < n; k++) {
        itens[k].classList.toggle('is-vista', k <= atual);
        itens[k].classList.toggle('is-atual', k === atual);
      }
    }

    /* ------------------------------------------------------------ partida */

    medir();
    passo();

    assinar(function () {
      /* Só remede quando a janela muda de tamanho. Rolar não move marcador,
         e getBoundingClientRect de cinco pontos por quadro seria layout
         forçado a cada quadro sem nada em troca. */
      if (larg !== innerWidth || alt !== innerHeight) medir();
      passo();
    });

    /* A altura da foto opcional é reservada pelo aspect-ratio do CSS, então
       a lista não cresce quando ela pinta. Isto é cinto e suspensório: se
       uma variação deixar a altura seguir a imagem, a medida se refaz em vez
       de ficar errada em silêncio. */
    var fotos = raiz.getElementsByTagName('img');
    for (i = 0; i < fotos.length; i++) {
      if (fotos[i].complete) continue;
      fotos[i].addEventListener('load', function () { medir(); passo(); });
    }
  }

  /* Movimento reduzido: nada liga. O estilo.css entrega o filete cheio e
     todos os marcadores acesos — a informação inteira, parada. */
  if (reduzido()) return;

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

Cinco etapas de um processo empilhadas numa coluna só: um filete vertical à esquerda, o conteúdo à direita. O filete tem duas camadas — um trilho fraco na cor das divisórias e, por cima, um preenchimento na cor de marca que cresce conforme a página desce. Ele para no marcador da etapa que está sendo lida, e o marcador acende ali. A pessoa vê em que ponto do processo está pelo próprio ato de rolar: não há barra de progresso, não há número de passo, não há nada para clicar.

Resolve a dobra mais pedida de um site de assessoria — o "como funciona" — quando ela precisa dizer três coisas ao mesmo tempo: quais são as etapas, em que ordem e quanto tempo cada uma leva. Uma lista numerada comum entrega a primeira e a segunda e perde a terceira no meio do parágrafo; uma grade de cards perde a ordem, porque numa grade a etapa 1 e a etapa 4 são vizinhas de tabela. O filete devolve a sequência de graça, e o prazo em pílula ganha um lugar fixo no desenho em vez de virar frase.


Qual irmão ela substitui, e em que caso

Três componentes da casa encostam neste território, e a escolha entre eles não é de gosto:


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho (.eyebrow) 2 a 3 palavras. "Como funciona", "O processo", "Do começo ao fim".
Título (h2.sec-title.ttl) 5 a 9 palavras, com o arco inteiro no texto e uma palavra em <em>: "Do diagnóstico ao comitê, em cinco etapas". Evite "Nosso processo", que não informa.
Apoio (.sec-lead) 1 a 2 frases. É aqui que a natureza dos prazos é dita — estimativa de calendário, não compromisso. Ver Compliance.
.etp-num Dois dígitos, sempre com zero à esquerda. É aria-hidden: quem numera para o leitor de tela é o <ol>.
.etp-prazo 3 a 5 palavras, sempre em faixa ou em cadência ("de 2 a 3 semanas", "relatório mensal", "a cada seis meses"). Nunca um número único: prazo cravado vira promessa. Acima de 22 caracteres a pílula quebra em duas linhas no celular — funciona, mas desalinha o ritmo da coluna.
.etp-titulo 2 a 4 palavras, substantivo. É o nome da etapa, não o que acontece nela: "Política de investimentos", não "Definimos junto a sua política".
.etp-texto 1 a 3 frases, 200 a 340 caracteres. Diz o que acontece e o que sai da etapa.
.etp-nota 1 a 2 frases. A ressalva dos prazos, no fim da lista e não no rodapé da página.
Quantidade 4 a 6 <li>, numerando --etp-i a partir de 0 e em sequência.

Comprimento parecido entre as etapas é estrutural, e não estética. A altura de cada item é o comprimento do segmento de filete que sai dele: um parágrafo com o dobro do tamanho do vizinho produz um trecho de filete com o dobro do tamanho, e o percurso deixa de parecer regular. Fique numa faixa de mais ou menos 30% entre o menor e o maior.

Nenhuma etapa fala de retorno. "Implementação" é onde a carteira é montada, não onde o dinheiro "começa a render" — ver Compliance.

Especificação dos assets

A foto é opcional e cabe em uma etapa, não em todas.


Como usar

<link rel="stylesheet" href="componentes/listas/linha-do-tempo-etapas/estilo.css">
<script src="componentes/listas/linha-do-tempo-etapas/script.js"></script>

Cada etapa declara a própria posição na sequência, a partir de 0:

<li class="etp-item" style="--etp-i:0">
<li class="etp-item" style="--etp-i:1">

Acrescentar ou tirar uma etapa é acrescentar ou tirar um <li> e continuar a numeração. Nada mais muda: a altura do segmento, o fim do filete e o alcance do progresso saem todos da própria lista.

Ganchos

Gancho Papel
data-etp raiz da dobra. O script parte daqui, nunca do documento — a peça pode repetir na mesma página.
data-rv no .etp-corpo de cada etapa, nunca no <li>. Ver Gotchas.
.ttl no h2.sec-title, como em toda dobra.
--etp-i índice do item, escrito à mão no markup.
--etp-k escrito pelo script na seção: o progresso em unidades de etapa (0 = primeiro marcador, 4 = quinto). Só de leitura para quem consome.
.is-live posta pelo script na seção. É ela que liga o filete dirigido e o apagamento dos marcadores.
.is-vista / .is-atual postas pelo script em cada .etp-item. Uma .is-atual por vez.

Parafusos

Variável O quê
--etp-linha fração da janela onde mora a linha de leitura. .5 é o meio da tela, o padrão.
--etp-max largura da coluna de leitura (760px).
--etp-col / --etp-gap-col largura da coluna do filete e vão até o texto.
--etp-vao respiro entre uma etapa e a seguinte.
--etp-topo-h altura da faixa do número e do prazo — é dela que sai --etp-cap, e é ela que alinha o marcador ao número.
--etp-suave suavização do preenchimento entre um valor e o seguinte (120ms).

Variações


Gotchas


Compliance

A peça devolve números de calendário, e é por isso que esta seção existe. Antes de publicar, o consumidor tem de fazer as quatro coisas abaixo.

  1. Trocar os prazos. Os intervalos do markup.html são exemplo editorial, calibrados para soarem plausíveis numa assessoria — não são compromisso de ninguém. Substituir pelos prazos reais do cliente, validá-los com quem executa o processo e datá-los na ficha do projeto.
  2. Manter o prazo em faixa. "De 2 a 3 semanas", nunca "15 dias". Prazo cravado numa peça de marketing é lido como compromisso, e o .etp-prazo tem largura para a faixa justamente para tirar a tentação.
  3. Manter a fronteira processo × resultado. Nenhuma etapa pode falar de rentabilidade, retorno, ganho ou desempenho. A armadilha é sempre a mesma e sempre na etapa de implementação: "onde seu dinheiro começa a render" é a frase que não pode existir. Aqui a etapa 03 diz o que acontece (a carteira é montada dentro das faixas aprovadas, na custódia que já é do cliente) e como o assessor é remunerado (taxa previamente acordada), e para por aí.
  4. Passar a ressalva pelo compliance do cliente. O texto da .etp-nota diz que os prazos são estimados, de onde eles são contados e o que os faz variar. Ele fica no fim da lista, onde a pessoa acabou de ler as pílulas — não no rodapé da página.

Já resolvido no componente e que não deve regredir: nenhum nome de instituição real, nenhum nome de cliente, nenhum superlativo de mercado, nenhum dado pessoal coletado e nada gravado — a peça não tem campo, não tem clique e não usa localStorage, cookie nem envio.


Histórico

Projeto Onde Data Diferenças
autoral padrão: linha do tempo vertical com filete preenchido pela rolagem 2026-09-06 construído do zero para a biblioteca; nunca rodou em produção

Origem. Componente autoral (§11 do contrato). Não há referência única: o padrão — coluna única, marcador por etapa, fio que acompanha a leitura — aparece em página de processo de consultoria, em documentação de produto e em onboarding de fintech, e é justamente por ser um lugar-comum bem resolvido que ele merecia uma versão da casa em vez de ser improvisado a cada site. A categoria listas tinha quatro componentes e nenhuma linha do tempo vertical.

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

  1. Coluna única, sem alternar lados. A alternância esquerda/direita é o desenho mais visto do padrão e o que menos sobrevive: em 390px os dois lados desabam para a mesma coluna e sobra um remendo com metade dos itens desalinhados. Processo se lê em sequência, não em simetria — e foi por ter recusado a alternância desde o começo que a media query de mobile precisou de quatro linhas, todas de escala.
  2. O trilho é por etapa e é calculado só em CSS. Cada .etp-item desenha o próprio segmento, do centro do seu marcador ao centro do seguinte, usando --etp-cap e um vazamento de --etp-cap para baixo da própria caixa. O script não mede altura de trilho nenhum, e o último item simplesmente não desenha segmento — de onde sai, de graça, a garantia de que o preenchimento nunca passa do último marcador.
  3. Um número, cinco segmentos. O script escreve um único --etp-k na seção; cada segmento tira a própria fatia com clamp(0, calc(var(--etp-k) - var(--etp-i)), 1). É a mesma economia do --wheel-angle do midia/roda-de-cards: uma escrita por quadro, e a distribuição fica no CSS.
  4. O repouso é o cheio. Sem script e com movimento reduzido a peça fica completa e parada, e é a classe .is-live do script que liga o comportamento — como faz o conversao/diagnostico-perfil. O contrário (nascer vazio, o script encher) deixaria a dobra em branco no dia em que o script não rodasse.
  5. A linha de leitura é grampeada nas pontas do documento. "Corrente é a etapa que cruzou o meio da janela" é o critério, e ele não fecha quando a dobra é a última da página. Converter cada marcador na rolagem que o traria até a linha, e grampear esse número em [0, scrollHeight - innerHeight], preserva a ordem (logo o progresso continua monótono) e garante o fecho no fim do documento.

Normalizações aplicadas: prefixo etp- em toda classe; zero cor crua (o véu da legenda usa rgba(var(--dark-rgb),…), que lê token); .pill, .visually-hidden, .eyebrow, .sec-title, .sec-lead, .wrap e .nota (a ressalva) vêm do core e não foram recopiadas; scroll pelo laço único LIB.onScroll; LIB.reduce consultado com o mesmo fallback dos irmãos, para o caso de o componente ser colado sem o motion.js; JS ES5-safe dentro de IIFE, com guard silencioso e busca a partir da raiz.

Divergências conhecidas vs. produção: nenhuma — não rodou em produção ainda.

O que foi medido (bin/medir.js, Chrome headless, tema liberta-wealth):

Medida Resultado
--etp-k em --scroll=0 / 0.5 / 0.9, a 1440 0.0000 / 3.8187 / 4.0000 — monótono
filete pintado, mesmas frações 0px / 900,8px / 944,9px (total dos quatro segmentos: 944,8px)
o mesmo em --mobile 0.0000 / 3.3199 / 4.0000; 0px / 1076px / 1302px
ponta do preenchimento × centro do último marcador 1378 × 1378 a 1440; 1772 × 1772 a 390 — nunca passa, em nenhuma fração
etapa corrente no máximo uma .is-atual em toda fração, em 1440 e em 390 (zero antes de o primeiro marcador cruzar a linha, que é o estado "a dobra ainda está entrando")
--reduce sem .is-live, escala 1 nos quatro segmentos, cinco marcadores em --accent
JavaScript desligado de verdade idem, e .etp-corpo em opacidade 1 (o @media (scripting:none) do core)
overflow horizontal ok em 1920, 1440, 1024, 768, 640, 390 e 360
alinhamento marcador × pílula do prazo 0,00px de desalinho nas cinco etapas, em 1440 e em 390
emenda entre segmentos 0,0px em três junções, 0,1px de arredondamento na quarta
redimensionar a janela no meio da rolagem medida refeita; ponta volta a cair sobre o último marcador (1378 → 1418 → 1378)
console limpo em todas as execuções