← Biblioteca componentes/overlay/drawer-lateral/ficha.md CódigoTela cheia
overlay/drawer-lateral

Drawer lateral de conteúdo

🟢 estavel complexidade alta prefixo drw- overlay drawer painel dialogo detalhe equipe dialog-nativo acessibilidade mobile

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

<link rel="stylesheet" href="componentes/overlay/drawer-lateral/estilo.css">
<script src="componentes/overlay/drawer-lateral/script.js"></script>

markup.html · 302 linhas · 16,5 KB

<!-- ==========================================================================
     DRAWER LATERAL — painel de detalhe que desliza da direita
     Prefixo: drw-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css + script.js

     A IDEIA. Uma grade de itens e UM painel só. No clique, o painel desliza da
     borda direita ocupando uma faixa da tela (520px no desktop, largura cheia
     no celular), a página escurece atrás e o detalhe do item aparece lá dentro.
     A pessoa lê, fecha, e continua exatamente onde estava — que é a diferença
     para navegar até outra página, e a razão de a peça existir: detalhar um
     item de uma lista sem tirar ninguém do lugar.

     NÃO CONFUNDIR COM navegacao/drawer-fullscreen. Aquele é MENU: cobre a tela
     inteira, tem cinco links e um CTA, e o conteúdo dele é fixo. Este é
     CONTEÚDO: ocupa uma faixa, deixa a página visível atrás e troca o miolo a
     cada item. A ficha diz qual usar em cada caso.

     <dialog> NATIVO, e é a segunda coisa que decide o arquivo. Do elemento vêm
     prontos: foco preso dentro, Esc, foco devolvido a quem abriu, e o resto da
     página inerte para tecnologia assistiva. É o mesmo critério do
     midia/showreel-expansivel. O que o <dialog> não dá — deslizar da direita em
     vez de nascer centrado, animar a SAÍDA (ele some na hora), travar a rolagem
     do fundo e fechar por clique no fundo — está no script e no CSS.

     VÁRIOS GATILHOS, UM PAINEL, CONTEÚDO TROCADO. O botão declara
     data-drw-abrir="<id do painel>" e data-drw-fonte="<id do bloco de
     conteúdo>". O conteúdo de cada item mora aqui embaixo, em .drw-fontes, como
     texto normal da página — e o script MOVE o miolo do bloco para dentro do
     painel ao abrir, devolvendo-o ao fechar. Move, não clona; a razão está no
     cabeçalho do script.js.

     SEM JAVASCRIPT A PÁGINA CONTINUA LEGÍVEL, e isso não é detalhe: os perfis
     são conteúdo editorial, não decoração. .drw-fontes NÃO nasce com [hidden] —
     quem o esconde é o script, na última linha dele, depois de tudo ligado.
     Sem script, a dobra lê como uma grade de cards seguida dos perfis em texto
     corrido.

     NOMES E TRAJETÓRIAS SÃO FICTÍCIOS. Nenhuma pessoa, instituição ou marca
     real. Datas e anos de casa são ILUSTRATIVOS — trocar pelos reais antes de
     publicar. Nada aqui promete retorno, e a ressalva de remuneração fica
     dentro do painel, onde a pessoa lê, e não num rodapé.
     ========================================================================== -->

<section class="sec-dark sec-pad drw-sec" id="equipe">
  <div class="wrap">

    <div class="sec-head">
      <span class="eyebrow" data-rv>Quem atende</span>
      <h2 class="sec-title ttl">Um nome, e a <em>casa inteira</em> atrás dele</h2>
      <p class="sec-lead" data-rv data-d="1">Quatro pessoas do time de assessoria. Abra uma para ler a trajetória sem sair desta página: o painel entra pela lateral e a lista continua onde estava.</p>
    </div>

    <!-- data-rv no <ul>, nunca no card: o §5 do contrato manda o reveal ficar
         num invólucro, e o card gerencia transform próprio no hover. -->
    <ul class="drw-grade" data-rv data-d="2">

      <li class="drw-item">
        <button class="drw-card glass glass-hover" type="button"
                data-drw-abrir="painel-equipe" data-drw-fonte="perfil-helena"
                aria-haspopup="dialog">
          <span class="drw-mono" aria-hidden="true">HD</span>
          <span class="drw-nome">Helena Duarte</span>
          <span class="drw-cargo">Assessora de investimentos</span>
          <span class="drw-areas">Sucessão · Holding familiar</span>
          <span class="drw-mais">Ver trajetória
            <svg viewBox="0 0 14 14" width="11" height="11" focusable="false" aria-hidden="true">
              <path d="M2 12L12 2M12 2H4M12 2v8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
        </button>
      </li>

      <li class="drw-item">
        <button class="drw-card glass glass-hover" type="button"
                data-drw-abrir="painel-equipe" data-drw-fonte="perfil-rafael"
                aria-haspopup="dialog">
          <span class="drw-mono" aria-hidden="true">RS</span>
          <span class="drw-nome">Rafael Sampaio</span>
          <span class="drw-cargo">Assessor de investimentos</span>
          <span class="drw-areas">Previdência · Liquidez</span>
          <span class="drw-mais">Ver trajetória
            <svg viewBox="0 0 14 14" width="11" height="11" focusable="false" aria-hidden="true">
              <path d="M2 12L12 2M12 2H4M12 2v8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
        </button>
      </li>

      <li class="drw-item">
        <button class="drw-card glass glass-hover" type="button"
                data-drw-abrir="painel-equipe" data-drw-fonte="perfil-marina"
                aria-haspopup="dialog">
          <span class="drw-mono" aria-hidden="true">MB</span>
          <span class="drw-nome">Marina Bastos</span>
          <span class="drw-cargo">Assessora de investimentos</span>
          <span class="drw-areas">Empresas · Caixa e tesouraria</span>
          <span class="drw-mais">Ver trajetória
            <svg viewBox="0 0 14 14" width="11" height="11" focusable="false" aria-hidden="true">
              <path d="M2 12L12 2M12 2H4M12 2v8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
        </button>
      </li>

      <li class="drw-item">
        <button class="drw-card glass glass-hover" type="button"
                data-drw-abrir="painel-equipe" data-drw-fonte="perfil-otavio"
                aria-haspopup="dialog">
          <span class="drw-mono" aria-hidden="true">OL</span>
          <span class="drw-nome">Otávio Lins</span>
          <span class="drw-cargo">Assessor de investimentos</span>
          <span class="drw-areas">Política de investimentos · Comitê</span>
          <span class="drw-mais">Ver trajetória
            <svg viewBox="0 0 14 14" width="11" height="11" focusable="false" aria-hidden="true">
              <path d="M2 12L12 2M12 2H4M12 2v8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
        </button>
      </li>

    </ul>

    <!-- ==================================================================
         AS FONTES — o conteúdo de verdade de cada item.
         Fica no HTML estático, e é o que a pessoa lê quando o script não
         roda. O bloco marcado com [data-drw-mover] é o que viaja para
         dentro do painel; o nome e o cargo ficam aqui e são COPIADOS como
         texto para o cabeçalho fixo, para não aparecerem duas vezes.

         Os rótulos internos ("Trajetória", "Onde costuma entrar") são
         parágrafos e não títulos, de propósito: o bloco vive em dois
         contextos com profundidade diferente — abaixo de um <h4> aqui na
         página e abaixo do <h2> do painel — e nenhum nível de heading está
         certo nos dois. O único heading real é o nome, que o painel
         redesenha no cabeçalho dele.
         ================================================================== -->
    <div class="drw-fontes" data-drw-fontes>
      <h3 class="drw-fontes-titulo">Perfis completos</h3>
      <p class="drw-fontes-nota">O painel lateral desta página depende de JavaScript. Abaixo, o mesmo conteúdo em texto corrido.</p>

      <article class="drw-fonte" id="perfil-helena">
        <h4 class="drw-fonte-nome" data-drw-nome>Helena Duarte</h4>
        <p class="drw-fonte-cargo" data-drw-cargo>Assessora de investimentos</p>

        <div class="drw-fonte-corpo" data-drw-mover>
          <ul class="drw-tags">
            <li><span class="pill">Sucessão</span></li>
            <li><span class="pill">Holding familiar</span></li>
            <li><span class="pill">Governança</span></li>
          </ul>

          <p class="drw-frase">“Patrimônio que atravessa gerações precisa de regra escrita antes de precisar de produto.”</p>

          <p class="drw-rot">Trajetória</p>
          <!-- Anos e datas: ILUSTRATIVOS. Trocar pelos reais. -->
          <p>Doze anos entre mesa de renda fixa e assessoria. Desde 2018 conduz na casa os casos em que a conversa começa pela família e não pela carteira: partilha, holding, acordo entre sócios que também são irmãos.</p>
          <p>Trabalha sempre com a política de investimentos escrita antes da primeira alocação — é o documento que o comitê revisa depois, e o que permite explicar a decisão de hoje daqui a três anos.</p>

          <p class="drw-rot">Onde costuma entrar</p>
          <ul class="drw-lista">
            <li>Famílias que já decidiram fazer a sucessão e não sabem por onde começar.</li>
            <li>Empresários com holding montada e política de investimentos ainda no boca a boca.</li>
            <li>Casos com mais de um assessor e nenhum relatório consolidado.</li>
          </ul>

          <p class="nota drw-nota">Assessoria remunerada por taxa previamente acordada com o cliente. Nada nesta página é recomendação de investimento nem projeção de resultado.</p>
        </div>
      </article>

      <article class="drw-fonte" id="perfil-rafael">
        <h4 class="drw-fonte-nome" data-drw-nome>Rafael Sampaio</h4>
        <p class="drw-fonte-cargo" data-drw-cargo>Assessor de investimentos</p>

        <div class="drw-fonte-corpo" data-drw-mover>
          <ul class="drw-tags">
            <li><span class="pill">Previdência</span></li>
            <li><span class="pill">Liquidez</span></li>
            <li><span class="pill">Reserva</span></li>
          </ul>

          <p class="drw-frase">“Liquidez é o que decide se um plano sobrevive ao ano em que nada sai como previsto.”</p>

          <p class="drw-rot">Trajetória</p>
          <p>Começou em previdência corporativa e passou a atender famílias em 2016. Cuida da parte menos fotogênica do trabalho: prazo, carência, resgate — o que acontece com o dinheiro no dia em que ele precisa aparecer.</p>
          <p>Monta o cronograma de liquidez junto com o diagnóstico, para que cada objetivo tenha um prazo declarado antes de ter um produto.</p>

          <p class="drw-rot">Onde costuma entrar</p>
          <ul class="drw-lista">
            <li>Reserva e caixa da família com destino ainda indefinido.</li>
            <li>Planos de previdência antigos que ninguém revisou desde a contratação.</li>
            <li>Alocação com prazo mais longo que o objetivo que ela financia.</li>
          </ul>

          <p class="nota drw-nota">Assessoria remunerada por taxa previamente acordada com o cliente. Nada nesta página é recomendação de investimento nem projeção de resultado.</p>
        </div>
      </article>

      <article class="drw-fonte" id="perfil-marina">
        <h4 class="drw-fonte-nome" data-drw-nome>Marina Bastos</h4>
        <p class="drw-fonte-cargo" data-drw-cargo>Assessora de investimentos</p>

        <div class="drw-fonte-corpo" data-drw-mover>
          <ul class="drw-tags">
            <li><span class="pill">Empresas</span></li>
            <li><span class="pill">Caixa</span></li>
            <li><span class="pill">Evento de liquidez</span></li>
          </ul>

          <p class="drw-frase">“O caixa da empresa e o patrimônio do sócio são duas conversas. Misturá-las custa caro nas duas.”</p>

          <p class="drw-rot">Trajetória</p>
          <p>Dez anos em tesouraria antes de vir para a assessoria. Atende empresários que chegam com a pessoa jurídica e a pessoa física no mesmo balanço mental, e o primeiro trabalho costuma ser separar as duas.</p>
          <p>Escreve com o cliente a política de aplicação do caixa: quanto fica disponível, em que prazo, e quem assina o quê.</p>

          <p class="drw-rot">Onde costuma entrar</p>
          <ul class="drw-lista">
            <li>Empresas com caixa parado e política de aplicação informal.</li>
            <li>Separação entre patrimônio pessoal e capital de giro.</li>
            <li>Preparação de caixa para um evento de liquidez já datado.</li>
          </ul>

          <p class="nota drw-nota">Assessoria remunerada por taxa previamente acordada com o cliente. Nada nesta página é recomendação de investimento nem projeção de resultado.</p>
        </div>
      </article>

      <article class="drw-fonte" id="perfil-otavio">
        <h4 class="drw-fonte-nome" data-drw-nome>Otávio Lins</h4>
        <p class="drw-fonte-cargo" data-drw-cargo>Assessor de investimentos</p>

        <div class="drw-fonte-corpo" data-drw-mover>
          <ul class="drw-tags">
            <li><span class="pill">Política de investimentos</span></li>
            <li><span class="pill">Comitê</span></li>
            <li><span class="pill">Diagnóstico</span></li>
          </ul>

          <p class="drw-frase">“Comitê existe para que a decisão de hoje possa ser explicada daqui a três anos.”</p>

          <p class="drw-rot">Trajetória</p>
          <p>Vem da área de análise. Redige as políticas de investimento da casa e conduz o comitê que revisa as carteiras a cada trimestre, com relatório consolidado e ata.</p>
          <p>É quem costuma abrir o diagnóstico: entender o que já existe, o que está duplicado e o que ninguém sabe explicar por que foi comprado.</p>

          <p class="drw-rot">Onde costuma entrar</p>
          <ul class="drw-lista">
            <li>Redação e revisão da política de investimentos da família.</li>
            <li>Diagnóstico inicial, antes de qualquer alocação nova.</li>
            <li>Revisão trimestral de carteira com relatório consolidado.</li>
          </ul>

          <p class="nota drw-nota">Assessoria remunerada por taxa previamente acordada com o cliente. Nada nesta página é recomendação de investimento nem projeção de resultado.</p>
        </div>
      </article>
    </div>

  </div>
</section>

<!-- ==========================================================================
     O PAINEL. Mora FORA da seção, como irmão dela, por dois motivos: dentro de
     uma dobra ele herdaria o escopo de tom dela, e o §5 do contrato proíbe um
     elemento posicionado em relação à janela dentro de qualquer [data-rv] —
     `blur(0)` continua sendo filtro, e filtro é bloco de contenção.

     .sec-deep no próprio elemento: o painel não está dentro de <section>
     nenhuma, então não herda --fg de ninguém. É a exceção que o §3 do manual
     descreve — entrar num escopo que o core já define, não sobrescrever token.

     .dialog-casca antes da classe local: é a primitiva do core que zera a
     caixa que o navegador dá ao <dialog> — padding, borda, fundo e os limites
     de max-content — e pinta o ::backdrop com --veil. Aqui o diálogo É o
     painel, então a superfície e a tinta voltam a ser declaradas no estilo.css
     por cima dela; ver o comentário do .drw.

     O aria-label nasce genérico e o script o troca pelo nome do item aberto.
     Rótulo por atributo, e não por aria-labelledby: id é único por documento, e
     o componente pode repetir na página.
     ========================================================================== -->
<dialog class="dialog-casca drw sec-deep" id="painel-equipe" data-drw aria-label="Detalhe do item">

  <div class="drw-cabeca">
    <div class="drw-cabeca-txt">
      <p class="drw-olho" data-drw-cargo-alvo>Assessoria</p>
      <h2 class="drw-titulo" data-drw-nome-alvo>Detalhe</h2>
    </div>
    <button class="btn-disc drw-fechar" type="button" data-drw-fechar autofocus aria-label="Fechar o painel">
      <svg viewBox="0 0 24 24" width="16" height="16" focusable="false" aria-hidden="true">
        <path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
      </svg>
    </button>
  </div>

  <!-- tabindex="-1" para o script poder mandar o foco ao corpo quando o item é
       TROCADO com o painel já aberto: ali o <dialog> não refaz o foco inicial,
       e sem isto o leitor de tela ficaria lendo o item anterior. -->
  <div class="drw-corpo" data-drw-alvo tabindex="-1"></div>

  <div class="drw-pe">
    <a class="btn btn-primary drw-cta" href="#contato">Falar com <span data-drw-cta-nome>a equipe</span></a>
    <p class="nota drw-pe-nota">Primeira conversa sem custo e sem compromisso. A assessoria é remunerada por taxa previamente acordada.</p>
  </div>

</dialog>

estilo.css · 422 linhas · 15,1 KB

/* ==========================================================================
   DRAWER LATERAL — prefixo drw-
   Grade de itens + UM painel <dialog> que desliza da direita ocupando uma
   faixa da tela. O detalhe do item entra dentro dele; a página fica visível,
   escurecida, atrás.

   Tokens usados:
     --accent           monograma, realce do card, botão do rodapé (via .btn)
     --bg               fundo do painel — vem do .sec-deep declarado no markup
     --veil             véu do ::backdrop — pintado pela .dialog-casca do core
     --display          nome do item, título do painel, rótulos internos
     --sans             corpo de texto
     --ease --dur       curva do projeto
     --fg --fg-2        tinta do título e do texto
     --fg-muted         cargo, rótulos, ressalva
     --rule --rule-2    filete do cabeçalho/rodapé e do monograma
     --radius-media     canto do card
     --radius-pill      via .pill e .btn-disc do core

   Parafusos do próprio componente (§3: local não pede conversa):
     --drw-w      largura do painel no desktop
     --drw-pad    respiro interno do painel — NÃO é o --pad do grid, que só
                  o .wrap consome
     --drw-dur    tempo do deslize; o script tem a mesma constante em ms
     --drw-faixa  passo do escalonamento entre cabeça, corpo e pé

   Parafuso da primitiva que este componente ajusta:
     --dialog-blur  desfoque do ::backdrop, 7px aqui contra os 6px do padrão

   Primitivas consumidas do core, nunca recopiadas: .wrap, .sec-pad,
   .sec-head, .eyebrow, .sec-title, .sec-lead, .glass/.glass-hover, .pill,
   .btn/.btn-primary, .btn-disc e a .dialog-casca — que zera a caixa que o
   navegador dá ao <dialog> e pinta o ::backdrop com --veil.

   A REGRA QUE ORGANIZA O ARQUIVO: nada aqui esconde os perfis. Quem esconde
   é o script, com [hidden], depois de tudo ligado. Sem JavaScript o CSS
   entrega a grade e os perfis em texto corrido, que é o que se quer.
   ========================================================================== */

/* ---- A grade de itens ---------------------------------------------------- */

.drw-grade{
  display:grid;
  grid-template-columns:repeat(4,minmax(0,1fr));
  gap:14px;
  margin-block-start:54px;
}

.drw-card{
  inline-size:100%;
  display:grid;
  justify-items:start;
  gap:6px;
  padding:26px 24px 22px;
  border-radius:var(--radius-media);
  text-align:start;
  color:var(--fg);
  cursor:pointer;
  /* A borda e o fundo vêm do .glass. Aqui só o que é do card. */
}
/* HOVER NÃO MEXE NO CARD (CLAUDE.md §6, regra de 2026-09-29). Ele subia 3px
   sob o ponteiro, e saiu: card que se desloca no hover é a mesma família do
   zoom de foto, e é o que quebra a página quando a grade é colada noutro
   lugar. O retorno fica na borda do .glass-hover e no disco do monograma,
   logo abaixo. */

.drw-mono{
  display:grid;
  place-items:center;
  inline-size:50px;
  block-size:50px;
  margin-block-end:14px;
  border:1px solid var(--rule-2);
  border-radius:var(--radius-pill);
  font-family:var(--display);
  font-weight:700;
  font-size:14px;
  letter-spacing:.08em;
  color:var(--accent);
  transition:border-color .45s var(--ease);
}
.drw-card:hover .drw-mono,
.drw-card:focus-visible .drw-mono{border-color:var(--accent)}

.drw-nome{
  font-family:var(--display);
  font-weight:700;
  font-size:19px;
  letter-spacing:-.015em;
  line-height:1.2;
  color:var(--fg);
}
.drw-cargo{font-size:13.5px;line-height:1.45;color:var(--fg-2)}
.drw-areas{
  margin-block-start:2px;
  font-size:11.5px;
  letter-spacing:.1em;
  text-transform:uppercase;
  color:var(--fg-muted);
}

/* O rótulo de ação nasce VISÍVEL, não no hover: toque não tem hover, e sem
   ele o card não avisa que abre alguma coisa (§3 do brief). O hover só
   desloca a seta. */
.drw-mais{
  display:inline-flex;
  align-items:center;
  gap:7px;
  margin-block-start:16px;
  padding-block-start:16px;
  border-block-start:1px solid var(--rule);
  inline-size:100%;
  font-family:var(--display);
  font-weight:700;
  font-size:10.5px;
  letter-spacing:.14em;
  text-transform:uppercase;
  color:var(--fg-2);
  transition:color .35s var(--ease);
}
.drw-card:hover .drw-mais,
.drw-card:focus-visible .drw-mais{color:var(--accent)}
.drw-mais svg{transition:transform .35s var(--ease)}
.drw-card:hover .drw-mais svg,
.drw-card:focus-visible .drw-mais svg{transform:translate(2px,-2px)}
@media(prefers-reduced-motion:reduce){
  .drw-mais svg{transition:none}
  .drw-card:hover .drw-mais svg,
  .drw-card:focus-visible .drw-mais svg{transform:none}
}

/* ---- As fontes: o conteúdo sem JavaScript -------------------------------- */

.drw-fontes{
  display:grid;
  gap:40px;
  margin-block-start:60px;
  padding-block-start:40px;
  border-block-start:1px solid var(--rule);
}
/* §6.5 do contrato: quem dá `display` a um bloco que às vezes é [hidden] tem
   de desligá-lo à mão — a regra de autor vence o display:none da folha do
   agente do usuário, e o bloco continuaria na tela com o atributo posto. */
.drw-fontes[hidden]{display:none}

.drw-fontes-titulo{
  font-family:var(--display);
  font-weight:700;
  font-size:20px;
  letter-spacing:-.015em;
  color:var(--fg);
}
.drw-fontes-nota{margin-block-start:8px;font-size:14px;color:var(--fg-muted);max-width:62ch}

.drw-fonte{display:grid;gap:4px}
.drw-fonte-nome{
  font-family:var(--display);
  font-weight:700;
  font-size:18px;
  letter-spacing:-.015em;
  color:var(--fg);
}
.drw-fonte-cargo{font-size:13.5px;color:var(--fg-muted)}

/* ---- O miolo do detalhe -------------------------------------------------
   Estas regras valem NOS DOIS lugares: no bloco de texto corrido da página
   (sem script) e dentro do painel (com script). É por isso que nenhuma delas
   se pendura em .drw-corpo — o mesmo nó vive nos dois contextos, e duplicar
   a folha por contexto seria manter duas verdades. */

.drw-fonte-corpo{
  display:grid;
  gap:16px;
  margin-block-start:16px;
  max-width:64ch;
  color:var(--fg-2);
  font-size:15.5px;
  line-height:1.62;
}

.drw-tags{display:flex;flex-wrap:wrap;gap:8px}

.drw-frase{
  font-family:var(--display);
  font-weight:700;
  font-size:clamp(17px,1.5vw,20px);
  line-height:1.32;
  letter-spacing:-.015em;
  color:var(--fg);
  padding-inline-start:16px;
  border-inline-start:1px solid var(--accent);
  text-wrap:balance;
}

.drw-rot{
  margin-block-start:6px;
  font-family:var(--sans);
  font-weight:400;
  font-size:11px;
  letter-spacing:.18em;
  text-transform:uppercase;
  color:var(--fg-muted);
}

.drw-lista{display:grid;gap:9px}
.drw-lista li{position:relative;padding-inline-start:18px}
.drw-lista li::before{
  content:"";
  position:absolute;
  inset-block-start:.72em;
  inset-inline-start:0;
  inline-size:8px;
  block-size:1px;
  background:var(--accent);
}

/* A ressalva fica ONDE A PESSOA LÊ, colada ao texto, e não num rodapé de
   página que ninguém abre. */
/* Tipografia pela primitiva .nota do core; margem e filete são posição.
   --nota-max:none porque a MESMA ressalva serve dois contêineres de largura
   oposta — o painel estreito e o texto corrido da página, quando não há
   JavaScript. Quem manda na medida é o contêiner, não um teto fixo. */
.drw-nota{
  margin-block-start:4px;
  padding-block-start:14px;
  border-block-start:1px solid var(--rule);
  --nota-max:none;
}

/* ==========================================================================
   O PAINEL
   ========================================================================== */

/* O <dialog> nasce display:none na folha do agente do usuário e só aparece
   com [open]. Declarar `display:flex` aqui, sem o [open], deixaria o painel
   visível o tempo todo com a página rolando por baixo — é a mesma armadilha
   do §6.5, na versão do elemento nativo. Por isso o display mora na regra do
   [open], e não aqui. */
.drw{
  --drw-w:520px;
  --drw-pad:clamp(22px,3vw,34px);
  --drw-dur:.44s;
  --drw-faixa:.09s;

  /* O <dialog> modal já é fixed no topo da pilha; repetir aqui é o que
     permite reescrever a posição sem depender do valor do agente do usuário.
     inset:0 + margin-inline-start:auto com largura fixa cola o painel na
     borda direita; block-size:100% o estica na altura. É assim que o
     elemento centralizado por padrão vira uma faixa lateral, sem forçar
     prioridade na declaração e sem tocar no core. */
  position:fixed;
  inset:0;
  margin:0 0 0 auto;
  inline-size:min(var(--drw-w),100%);
  block-size:100%;
  /* Padding, borda e os limites de max-content são da .dialog-casca. Estas
     duas NÃO são: aqui o <dialog> É o painel, e não um palco transparente com
     um painel dentro. A primitiva devolve `background:transparent` e
     `color:inherit` para zerar a caixa do navegador; a superfície e a tinta
     vêm do escopo de tom que o markup declara no próprio diálogo, e por isso
     continuam escritas. */
  background:var(--bg);
  color:var(--fg);
  /* `hidden` e não o `visible` da primitiva, e a diferença é visível: cabeça,
     corpo e pé entram com filter:blur(12px), e o desfoque de um item que
     ocupa a largura inteira da faixa pinta PARA FORA dela — sobre o véu, à
     esquerda da borda do painel. Clipar na caixa do diálogo é o que mantém o
     deslize com uma borda só. */
  overflow:hidden;
  /* Fecha por arrasto horizontal no toque sem roubar a rolagem vertical do
     corpo: pan-y devolve ao navegador o gesto vertical e entrega ao script
     só o horizontal. pinch-zoom fica declarado para não desligar o zoom de
     acessibilidade, que `pan-y` sozinho tiraria. */
  touch-action:pan-y pinch-zoom;
  transform:translateX(100%);
  transition:transform var(--drw-dur) var(--ease);
}
.drw[open]{display:flex;flex-direction:column}
.drw.is-aberto{transform:none}
/* Durante o arrasto o transform vem por style inline, quadro a quadro: uma
   transição aqui atrasaria o painel em relação ao dedo. */
.drw.is-arrastando{transition:none}

/* O véu e o desfoque são da .dialog-casca. Sobram aqui duas coisas: o grau do
   desfoque e a coreografia de opacidade, que a primitiva não cobre.

   O VÉU ESCURECIA 74% E PASSOU AOS 78% DO TOKEN, e a decisão foi medida, não
   dada: esta peça existe para manter o contexto visível atrás, então adotar um
   véu mais fundo tinha de ser conferido. Fotografada a faixa de 905x900 que
   fica à esquerda do painel, a 1440x900: a mediana da página atrás fica no
   cinza 12 nos dois graus, e o 0,1% mais claro dela cai do cinza 40 para o 36
   — quatro níveis em 255. O contraste interno do que se vê atrás vai de 1,33
   para 1,26, e nos dois casos está muito abaixo de qualquer limite de leitura:
   ali a página é PRESENÇA, não texto. E o fundo do próprio painel é o cinza 24,
   entre a mediana e o pico — ou seja, a página continua se lendo atrás dele.
   Quatro pontos por um número que deixa de viver em dois lugares.

   O --dialog-blur é declarado NO PSEUDO-ELEMENTO, e não no .drw: até o Chrome
   121 o ::backdrop não herdava custom property do <dialog> de origem, e um
   valor posto no diálogo chegaria vazio — o desfoque cairia no default de 6px
   sem avisar. Declarado na própria regra do ::backdrop, não há herança no
   caminho. (O --veil da primitiva ainda depende dela; ver ficha.md.) */
.drw::backdrop{
  --dialog-blur:7px;
  opacity:0;
  transition:opacity var(--drw-dur) var(--ease);
}
.drw.is-aberto::backdrop{opacity:1}

/* ---- Cabeçalho parado, corpo rolando, rodapé parado ---------------------- */

.drw-cabeca{
  flex:0 0 auto;
  display:flex;
  align-items:flex-start;
  justify-content:space-between;
  gap:18px;
  padding:26px var(--drw-pad) 20px;
  border-block-end:1px solid var(--rule);
}
.drw-cabeca-txt{min-inline-size:0}
.drw-olho{
  font-size:11px;
  letter-spacing:.18em;
  text-transform:uppercase;
  color:var(--fg-muted);
}
.drw-titulo{
  margin-block-start:8px;
  font-family:var(--display);
  font-weight:700;
  font-size:clamp(24px,2.4vw,30px);
  line-height:1.14;
  letter-spacing:-.022em;
  color:var(--fg);
}
.drw-fechar{--disc-size:40px;margin-block-start:2px}

.drw-corpo{
  flex:1 1 auto;
  /* Sem isto o item de flex nunca encolhe abaixo do conteúdo, o painel
     estica além da janela e quem rola é a página, não o corpo. */
  min-block-size:0;
  overflow-y:auto;
  /* A rolagem do corpo não empurra a página atrás quando chega na ponta. */
  overscroll-behavior:contain;
  scrollbar-width:thin;
  padding:26px var(--drw-pad) 30px;
  /* O corte do corpo é indicado por máscara, não por sombra: a folha do
     projeto é flat, e sombra ali leria como caixa dentro de caixa. */
  mask-image:linear-gradient(to bottom,transparent 0,#000 20px,#000 calc(100% - 34px),transparent 100%);
}
.drw-corpo:focus{outline:none}
.drw-corpo:focus-visible{outline:2px solid var(--accent);outline-offset:-4px}

.drw-pe{
  flex:0 0 auto;
  display:grid;
  justify-items:start;
  gap:12px;
  padding:20px var(--drw-pad) 24px;
  border-block-start:1px solid var(--rule);
}
/* .drw-pe-nota não tem regra própria: a primitiva .nota do core entrega a
   tipografia inteira e a posição é do .drw-pe, que é um grid. A classe fica
   no markup como gancho para quem consumir a peça. */

/* ---- A entrada do conteúdo, em três faixas ------------------------------
   Nada entra seco (§5): as três regiões do painel entram com blur indo a
   zero, escalonadas.

   O ESCOPO `.drw` NA FRENTE NÃO É ENFEITE. O estado de repouso é invisível,
   e o corpo do detalhe também vive FORA do painel, na página, quando não há
   script. Se estas regras não estivessem presas ao painel, a página sem
   JavaScript renderizaria os quatro perfis em opacity:0 — conteúdo montado,
   correto e invisível, que é exatamente o bug que o @media(scripting:none)
   do core existe para evitar. */
.drw .drw-cabeca,
.drw .drw-corpo,
.drw .drw-pe{
  opacity:0;
  filter:blur(12px);
  transform:translateY(12px);
  transition:opacity .55s var(--ease),filter .6s var(--ease),transform .55s var(--ease);
}
.drw.is-aberto .drw-cabeca,
.drw.is-aberto .drw-corpo,
.drw.is-aberto .drw-pe{opacity:1;filter:blur(0);transform:none}
.drw.is-aberto .drw-corpo{transition-delay:var(--drw-faixa)}
.drw.is-aberto .drw-pe{transition-delay:calc(var(--drw-faixa) * 2)}

@media(prefers-reduced-motion:reduce){
  /* O painel continua abrindo e fechando — foi pedido. Some só a
     coreografia: nem deslize, nem blur, nem escalonamento.
     Os dois grupos de seletor estão escritos porque a regra de abertura
     vale (0,3,0) e uma versão curta aqui, de (0,2,0), perderia em silêncio
     — e `blur(0)` ainda é filtro, com tudo que isso implica. */
  .drw{transform:none;transition:none}
  .drw .drw-cabeca,
  .drw .drw-corpo,
  .drw .drw-pe,
  .drw.is-aberto .drw-cabeca,
  .drw.is-aberto .drw-corpo,
  .drw.is-aberto .drw-pe{
    opacity:1;filter:none;transform:none;transition:none;transition-delay:0s;
  }
}

/* ---- Mobile ------------------------------------------------------------- */

@media(max-width:1080px){
  .drw-grade{grid-template-columns:repeat(2,minmax(0,1fr))}
}

@media(max-width:640px){
  .drw{--drw-w:100%}
  .drw-grade{grid-template-columns:minmax(0,1fr);gap:12px;margin-block-start:40px}
  .drw-card{padding:22px 20px 20px}
  .drw-mono{inline-size:44px;block-size:44px;margin-block-end:12px}
  .drw-cabeca{padding-block-start:22px}
  .drw-fontes{gap:32px}
}

script.js · 319 linhas · 13,7 KB

/* ==========================================================================
   DRAWER LATERAL — prefixo drw-
   Um painel <dialog>, vários gatilhos, conteúdo trocado a cada abertura.

   O QUE ESTE SCRIPT NÃO FAZ, PORQUE O <dialog> JÁ FAZ: prender o foco dentro
   do painel, fechar no Esc, devolver o foco ao botão que abriu e tornar o
   resto da página inerte para tecnologia assistiva. Reimplementar qualquer
   um dos quatro seria trocar comportamento testado por bug futuro. É o mesmo
   critério do midia/showreel-expansivel.

   O QUE ELE FAZ, na ordem:
     1. liga cada [data-drw-abrir] ao painel e à fonte que ele nomeia;
     2. ao abrir, MOVE o miolo do bloco de conteúdo para dentro do painel e
        copia nome e cargo para o cabeçalho fixo;
     3. anima a SAÍDA antes de chamar close() — o <dialog> some na hora, e um
        painel que desliza para dentro e desaparece para fora lê como falha;
     4. pede a trava da rolagem ao core (LIB.travarRolagem) e a devolve no
        fechamento;
     5. fecha por arrasto horizontal no toque;
     6. só então esconde os blocos de conteúdo da página.

   MOVE, NÃO CLONA — e a escolha tem custo dos dois lados, então vale dizer
   por quê. Clonar duplicaria, enquanto o painel está aberto, TODO id que o
   bloco de conteúdo carregue: o próprio <article id="perfil-…">, que é o que
   o gatilho nomeia, e qualquer âncora dentro da prosa. Com id repetido,
   getElementById passa a devolver a cópia da página em vez da que está na
   tela, aria-labelledby aponta para o nó errado e o mesmo texto aparece duas
   vezes na árvore de acessibilidade — silenciosamente, e justamente para
   quem o <dialog> nativo foi escolhido para servir.

   O risco do lado de cá é um só: se o script morrer com o bloco fora do
   lugar, ele some da página. Três coisas fecham esse risco:
     · a devolução acontece no evento 'close' do <dialog>, que é o funil
       ÚNICO de todo caminho de fechamento (Esc, botão, clique no fundo,
       arrasto, close() de terceiro);
     · toda abertura devolve primeiro o que estiver dentro do painel, então
       um estado sujo se corrige na interação seguinte;
     · o bloco só é escondido da página na ÚLTIMA linha deste arquivo. Um
       erro em qualquer ponto antes disso deixa a página no estado sem
       JavaScript, com os quatro perfis legíveis, em vez de vazia.

   BUSCA POR getElementById, E ISSO NÃO CONTRADIZ O §7.6. A regra manda
   partir da raiz do componente quando ele pode repetir na página; aqui o
   pareamento entre gatilho e painel É o id que o próprio gatilho nomeia
   (data-drw-abrir / data-drw-fonte). Dois drawers na mesma página usam dois
   ids, e cada gatilho continua achando o seu.
   ========================================================================== */

(function () {

  var paineis = document.querySelectorAll('[data-drw]');
  if (!paineis.length) return;                 // guard: saída silenciosa

  /* Bate com --drw-dur no estilo.css (.44s). Mudar um pede mudar o outro. */
  var SAIDA  = 440;
  /* Arrasto mínimo para fechar. O teto de 30% da largura existe para o
     painel de largura cheia do celular não exigir metade da tela. */
  var LIMIAR = 96;

  /* Um registro por painel. Guarda de onde o bloco veio (para devolvê-lo no
     lugar exato), o timer da saída e o estado do arrasto. Array em vez de
     propriedade pendurada no elemento: o DOM não é o lugar de guardar estado
     de script. O estado da trava de rolagem NÃO está aqui: é do core, que
     conta as travas de todas as sobreposições da página. */
  var estados = [];

  function estado(painel) {
    for (var i = 0; i < estados.length; i++) {
      if (estados[i].painel === painel) return estados[i];
    }
    var novo = {
      painel: painel,
      no: null, pai: null, prox: null,       // de onde o bloco movido veio
      timer: 0,
      x0: 0, y0: 0, dx: 0,
      decidiu: false, arrasta: false, engole: false
    };
    estados.push(novo);
    return novo;
  }

  function reduzido() {
    return !!(window.LIB && window.LIB.reduce);
  }

  function texto(raiz, sel) {
    var el = raiz.querySelector(sel);
    return el ? (el.textContent || '').replace(/\s+/g, ' ').replace(/^ | $/g, '') : '';
  }

  function escreve(raiz, sel, valor) {
    var el = raiz.querySelector(sel);
    if (el && valor) el.textContent = valor;
  }

  /* ---- mover e devolver --------------------------------------------------
     nextSibling é suficiente para lembrar a posição porque nada mais mexe na
     lista de irmãos: o único código que move nós ali é este, e ele nunca tem
     dois blocos fora ao mesmo tempo (toda abertura devolve antes de tirar). */
  function devolver(e) {
    if (!e.no) return;
    if (e.prox && e.prox.parentNode === e.pai) e.pai.insertBefore(e.no, e.prox);
    else e.pai.appendChild(e.no);
    e.no = null; e.pai = null; e.prox = null;
  }

  function solta(painel, e) {
    painel.classList.remove('is-arrastando');
    painel.style.removeProperty('transform');
    e.dx = 0;
  }

  /* ---- trava da rolagem de fundo ----------------------------------------
     Do core. LIB.travarRolagem escreve overflow:hidden no <html> — e não no
     <body>, como este arquivo fazia —, guarda o valor anterior e conta as
     travas, o que a versão local não podia fazer: com um aviso de cookies
     aberto junto, o primeiro a fechar destravava a página por baixo do outro.
     O alvo também mudou de propósito: o <html> é quem rola, o scrollTop dele
     não é zerado por overflow:hidden (a posição volta sozinha, sem scrollTo e
     sem salto) e escrever no <body> apagava o overflow-x:hidden do core/base.
     Medido: scrollY 211 antes, com o painel aberto e depois de fechar, e
     clientWidth 1440 nos três momentos — a calha de rolagem continua reservada
     pelo scrollbar-gutter:stable do core, e a página não pula de largura.

     O guard cobre o consumidor que colou o componente sem o motion.js: sem a
     trava a página rola atrás do painel, o que é feio; sem o guard o painel
     não abre, o que é bug. */
  function trava() {
    if (window.LIB && window.LIB.travarRolagem) window.LIB.travarRolagem();
  }

  function destrava() {
    if (window.LIB && window.LIB.soltarRolagem) window.LIB.soltarRolagem();
  }

  /* ---- abrir ------------------------------------------------------------- */
  function abrir(botao) {
    var painel = document.getElementById(botao.getAttribute('data-drw-abrir'));
    var fonte  = document.getElementById(botao.getAttribute('data-drw-fonte'));
    if (!painel || !fonte) return;

    var alvo  = painel.querySelector('[data-drw-alvo]');
    var bloco = fonte.querySelector('[data-drw-mover]');
    if (!alvo || !bloco) return;

    var e = estado(painel);
    if (e.timer) { clearTimeout(e.timer); e.timer = 0; }
    devolver(e);                               // limpa o item anterior

    e.pai  = bloco.parentNode;
    e.prox = bloco.nextSibling;
    e.no   = bloco;
    alvo.appendChild(bloco);
    alvo.scrollTop = 0;

    var nome  = texto(fonte, '[data-drw-nome]');
    var cargo = texto(fonte, '[data-drw-cargo]');
    escreve(painel, '[data-drw-nome-alvo]', nome);
    escreve(painel, '[data-drw-cargo-alvo]', cargo);
    escreve(painel, '[data-drw-cta-nome]', nome.split(' ')[0] || nome);
    /* aria-label e não aria-labelledby: rótulo por id obrigaria o painel a
       ter um id interno único, e o componente pode repetir na página. */
    if (nome) painel.setAttribute('aria-label', cargo ? nome + ' — ' + cargo : nome);

    /* Trocar de item com o painel JÁ aberto: showModal() num <dialog> aberto
       lança InvalidStateError, e o foco não é refeito pelo elemento. Manda-se
       o foco ao corpo para o leitor de tela não continuar no item anterior. */
    if (painel.open) {
      painel.classList.add('is-aberto');
      if (alvo.focus) alvo.focus();
      return;
    }

    trava();
    painel.showModal();

    if (reduzido()) { painel.classList.add('is-aberto'); return; }

    /* Dois quadros de espera, não um. Com um só, o navegador às vezes agrupa
       a exibição do diálogo com a troca de classe e a transição não chega a
       existir — o painel aparece no lugar final, sem deslizar. */
    requestAnimationFrame(function () {
      requestAnimationFrame(function () { painel.classList.add('is-aberto'); });
    });
  }

  /* ---- fechar -----------------------------------------------------------
     Anima primeiro, close() depois: o <dialog> some no mesmo quadro em que
     close() é chamado, e sem a espera o painel entra deslizando e sai
     piscando. */
  function fechar(painel) {
    var e = estado(painel);
    painel.classList.remove('is-aberto');
    solta(painel, e);
    if (reduzido()) { painel.close(); return; }
    if (e.timer) clearTimeout(e.timer);
    e.timer = setTimeout(function () { e.timer = 0; painel.close(); }, SAIDA);
  }

  /* ---- arrasto lateral no toque -----------------------------------------
     O gesto vertical continua rolando o corpo do painel: quem separa os dois
     é o `touch-action:pan-y` do CSS, que entrega ao script só o movimento
     horizontal. Sem ele, seria preciso preventDefault no touchmove e a
     rolagem do corpo morreria junto.

     Só toque. Com mouse, arrastar dentro de um painel de texto é seleção de
     texto, não gesto — e roubá-la seria pior que não ter o atalho. */
  function aoPegar(ev) {
    if (ev.pointerType !== 'touch') return;
    var e = estado(this);
    e.x0 = ev.clientX; e.y0 = ev.clientY; e.dx = 0;
    e.decidiu = false; e.arrasta = false;
  }

  function aoMover(ev) {
    if (ev.pointerType !== 'touch') return;
    var painel = this, e = estado(painel);
    if (!e.decidiu) {
      var ax = Math.abs(ev.clientX - e.x0);
      var ay = Math.abs(ev.clientY - e.y0);
      if (ax < 8 && ay < 8) return;           // ainda é toque, não gesto
      e.decidiu = true;
      e.arrasta = ax > ay;
      if (e.arrasta) {
        painel.classList.add('is-arrastando');
        /* Alguns navegadores lançam se o ponteiro já não está ativo, e o
           evento sintético de um teste não tem ponteiro nenhum. A captura é
           conforto, não requisito: sem ela o arrasto ainda funciona. */
        try { painel.setPointerCapture(ev.pointerId); } catch (err) {}
      }
    }
    if (!e.arrasta) return;
    /* Só para a direita: puxar para dentro esticaria o painel para fora da
       tela do lado oposto. */
    e.dx = Math.max(0, ev.clientX - e.x0);
    painel.style.transform = 'translateX(' + e.dx.toFixed(1) + 'px)';
  }

  function aoSoltar() {
    var painel = this, e = estado(painel);
    if (!e.arrasta) return;
    e.arrasta = false; e.decidiu = false;
    e.engole = true;                          // mata o clique que vem a seguir
    var limite = Math.min(LIMIAR, painel.offsetWidth * 0.3);
    if (e.dx > limite) fechar(painel);
    else solta(painel, e);
  }

  /* ---- ligação ----------------------------------------------------------- */
  for (var i = 0; i < paineis.length; i++) {
    (function (painel) {

      /* Capture, e antes de tudo: um arrasto que termina sobre um link não
         pode navegar. Só engole o clique imediatamente posterior ao gesto. */
      painel.addEventListener('click', function (ev) {
        var e = estado(painel);
        if (!e.engole) return;
        e.engole = false;
        ev.preventDefault();
        ev.stopPropagation();
      }, true);

      /* Clique no fundo fecha. O <dialog> não faz isso sozinho, e é a única
         coisa que falta nele. O alvo ser o próprio painel significa que o
         clique caiu no ::backdrop — dentro, o alvo seria cabeça, corpo ou pé.
         Só funciona porque o painel não tem padding: com padding, a faixa dele
         também devolveria o painel como alvo. O zero vem da .dialog-casca, que
         serve `padding:var(--dialog-pad,0)` — e este componente NÃO declara a
         variável, de propósito. Quem for encostar nela precisa saber que paga
         o clique no fundo. */
      painel.addEventListener('click', function (ev) {
        if (ev.target === painel) fechar(painel);
      });

      painel.querySelectorAll('[data-drw-fechar]').forEach(function (b) {
        b.addEventListener('click', function () { fechar(painel); });
      });

      /* Esc dispara 'cancel' antes de 'close'. Barrar o padrão e passar pelo
         nosso fechar() é o que dá ao teclado a mesma saída animada do mouse. */
      painel.addEventListener('cancel', function (ev) {
        ev.preventDefault();
        fechar(painel);
      });

      /* O funil único. Qualquer caminho de fechamento passa por aqui — e é
         por isso que a devolução do bloco e a destrava do scroll moram neste
         lugar, e não espalhadas por cada gatilho. */
      painel.addEventListener('close', function () {
        var e = estado(painel);
        if (e.timer) { clearTimeout(e.timer); e.timer = 0; }
        painel.classList.remove('is-aberto');
        solta(painel, e);
        devolver(e);
        destrava();
      });

      painel.addEventListener('pointerdown', aoPegar);
      painel.addEventListener('pointermove', aoMover);
      painel.addEventListener('pointerup', aoSoltar);
      painel.addEventListener('pointercancel', aoSoltar);

    })(paineis[i]);
  }

  document.querySelectorAll('[data-drw-abrir]').forEach(function (botao) {
    botao.addEventListener('click', function () { abrir(botao); });
  });

  /* ÚLTIMA LINHA, e de propósito. Até aqui a página está no estado sem
     JavaScript: grade de cards mais os perfis em texto corrido. Se qualquer
     coisa acima tivesse falhado, o conteúdo continuaria legível em vez de
     desaparecer. Esconder no CSS seria o contrário: o conteúdo sumiria
     mesmo quando o script não roda. */
  document.querySelectorAll('[data-drw-fontes]').forEach(function (bloco) {
    bloco.hidden = true;
  });

})();

Uma grade de itens e um painel só. No clique, o painel entra deslizando da borda direita e ocupa uma faixa da tela — 520px no desktop, largura cheia no celular —, com o resto da página escurecido e desfocado atrás. Cabeçalho e rodapé ficam parados; o corpo rola por dentro, com o corte indicado por máscara em gradiente. O olho não muda de página: ele ganha uma segunda coluna que aparece por cima da primeira.

O problema editorial é o do detalhe que não cabe no card e não merece uma página. Trajetória de um assessor, escopo de um serviço, o que acontece numa etapa do processo, a linha de uma tabela: são 150 a 300 palavras que ninguém vai procurar num link, e que estufam a grade se ficarem soltas nela. O drawer as guarda até serem pedidas e devolve a pessoa exatamente onde ela estava — que é a diferença inteira para navegar.


Quando usar

Quando não usar


Este ou o navegacao/drawer-fullscreen?

Os dois deslizam da direita e os dois se chamam drawer, e é onde a semelhança acaba.

O navegacao/drawer-fullscreen é menu. Cobre a tela inteira e opaca, tem conteúdo fixo — os mesmos cinco links e o mesmo CTA, sempre —, é aberto pelo hambúrguer da nav e existe só no celular. O que ele faz é levar a pessoa embora: todo clique lá dentro fecha o painel e navega. Como não há nada por trás para se ver, cobrir tudo é o certo.

O overlay/drawer-lateral é conteúdo. Ocupa uma faixa e deixa a página visível atrás de propósito, porque o contexto — a grade de onde o item saiu — é metade da informação. O conteúdo dele troca a cada item, e o painel é um só para todos os gatilhos. Existe em todas as larguras. O que ele faz é o oposto do menu: devolver a pessoa ao mesmo ponto.

Regra curta: navegar é drawer-fullscreen; detalhar é drawer-lateral. Se um dia a nav precisar de um painel que mostre a página por trás, a variação é do menu, não deste. E se o detalhe pedir a tela inteira com foto, não é nenhum dos dois: é overlay/card-expansivel-fullscreen.

Nenhum componente catalogado resolvia isto antes: o card-expansivel-fullscreen cobre a tela e carrega o detalhe nos data-* do card (bom para texto curto, ruim para prosa com listas e ressalva); o drawer-fullscreen é menu; e o conteudo/grid-equipe mostra a grade mas não tem para onde abrir. Este componente substitui o card-expansivel-fullscreen quando o detalhe é texto estruturado e o contexto precisa continuar visível — e é o par natural do grid-equipe, que ele pode substituir por inteiro quando cada pessoa tiver perfil para ler.


Padrão editorial

Slot Regra
Olho Rótulo da coleção, não do item. "Quem atende", "Serviços", "Etapas".
Título Frase da dobra, não lista de nomes. Os nomes estão nos cards.
Apoio Uma linha que avisa que abre: "abra uma para ler a trajetória sem sair desta página". Sem isso, metade das pessoas não clica em card.
Monograma Duas letras, iniciais. Placeholder honesto enquanto não há retrato.
Nome do card Até 3 palavras. É o que vira título do painel.
Cargo Uma linha, sem sigla interna da casa.
Áreas Duas ou três, separadas por ·. Caixa alta pequena, é etiqueta e não frase.
Rótulo de ação O mesmo texto nos quatro cards ("Ver trajetória"). Variar por card lê como quatro coisas diferentes.
Frase do painel Uma linha em display, entre aspas, com filete na cor de marca. É a voz da pessoa, não o slogan da empresa.
Corpo Dois parágrafos de trajetória e uma lista de 3 itens de "onde costuma entrar". Mantenha o mesmo comprimento nos quatro: o painel tem altura fixa, e um item muito mais curto que o vizinho denuncia.
Ressalva Sempre a última coisa do corpo, dentro do painel. Nunca no rodapé da página.
CTA Verbo de conversa, nunca de compra. O script troca o primeiro nome sozinho.

Quatro itens é o número que o desenho pede: a grade é de quatro colunas no desktop, duas em tablet, uma no celular. Oito também fecha (duas fileiras). Três deixa uma coluna órfã — nesse caso troque --drw-w e a contagem de colunas juntos.

Especificação dos assets

O componente não usa asset nenhum no padrão: o monograma é texto, e os ícones são SVG inline de 11px e 16px, desenhados aqui.

Se o cliente tiver retratos e você quiser trocar o monograma por foto:


Como usar

<link rel="stylesheet" href="componentes/overlay/drawer-lateral/estilo.css">
<script src="componentes/overlay/drawer-lateral/script.js"></script>

O painel é um <dialog> que mora fora da seção, como irmão dela — dentro de uma dobra ele herdaria o escopo de tom, e o §5 do contrato proíbe elemento posicionado em relação à janela dentro de qualquer [data-rv].

<dialog class="dialog-casca drw sec-deep" id="painel-equipe" data-drw aria-label="Detalhe do item"> … </dialog>

.dialog-casca antes da classe local, e ela não é opcional. É a primitiva do core que zera a caixa que o navegador dá ao <dialog> — padding, borda, fundo e os limites de max-content — e pinta o ::backdrop com --veil. Aqui há uma diferença que vale saber: neste componente o <dialog> é o painel, não um palco transparente com um painel dentro, então a superfície (background:var(--bg)) e a tinta (color:var(--fg)) continuam declaradas no estilo.css, por cima do transparent/inherit da primitiva. E o padding tem de continuar zero: é o que faz o clique no fundo ter alvo (ver Gotchas).

Item novo custa dois blocos e nenhuma linha de script:

<!-- 1. o gatilho, dentro da grade -->
<button class="drw-card glass glass-hover" type="button"
        data-drw-abrir="painel-equipe" data-drw-fonte="perfil-novo"
        aria-haspopup="dialog"> … </button>

<!-- 2. o conteúdo, dentro de .drw-fontes -->
<article class="drw-fonte" id="perfil-novo">
  <h4 class="drw-fonte-nome" data-drw-nome>Nome</h4>
  <p class="drw-fonte-cargo" data-drw-cargo>Cargo</p>
  <div class="drw-fonte-corpo" data-drw-mover> … </div>
</article>

Qualquer elemento da página com [data-drw-abrir] passa a abrir o painel que o valor nomeia — o gatilho não precisa estar na grade nem na seção.

Ganchos

Gancho Papel
[data-drw] o <dialog> do painel. Precisa de id.
[data-drw-abrir] gatilho; o valor é o id do painel
[data-drw-fonte] no gatilho; o valor é o id do bloco de conteúdo
[data-drw-fontes] invólucro dos blocos; o script o esconde na última linha
[data-drw-mover] o miolo que viaja para dentro do painel e volta
[data-drw-nome] / [data-drw-cargo] texto copiado para o cabeçalho fixo
[data-drw-alvo] onde o miolo é encaixado dentro do painel
[data-drw-nome-alvo] / [data-drw-cargo-alvo] / [data-drw-cta-nome] destinos do texto copiado
[data-drw-fechar] botão de fechar
.drw.is-aberto estado aberto: deslize e entrada em três faixas
.drw.is-arrastando arrasto de toque em curso; desliga a transição
--drw-w largura do painel no desktop (520px)
--drw-pad respiro interno do painel — não é o --pad do grid
--drw-dur tempo do deslize (.44s); o script tem a mesma constante em ms
--drw-faixa passo do escalonamento entre cabeça, corpo e pé (.09s)
.dialog-casca primitiva do core no <dialog>, antes da classe local: zera a caixa do navegador e pinta o ::backdrop com --veil
--dialog-blur parafuso da primitiva: desfoque do ::backdrop. 7px aqui, contra os 6px do padrão. Declarado no .drw::backdrop, e não no .drw — ver Gotchas

Variações


Compliance

A peça não calcula nem compara nada, mas fala em nome de pessoas e termina num CTA, e isso já é terreno regulado. Antes de publicar:


Gotchas


Histórico

Projeto Onde Data Diferenças
autoral — 2026-09-06 Construído aqui, sem referência única.

Origem. Componente autoral. O padrão — painel lateral de detalhe, side drawer ou off-canvas panel — aparece em três famílias de exemplo: carrinho e filtros de e-commerce, painel de referência de documentação técnica, e perfil de pessoa aberto sobre a grade em site institucional premium. Nenhuma linha veio de lugar nenhum; o que veio das famílias foi a proporção (faixa de ~500px contra tela cheia), o vocabulário de interação (cabeçalho parado, corpo rolando, arrastar para fechar no toque) e a expectativa de que o fundo continue visível.

O que a tradução para o contrato decidiu. Três coisas, e as três são o oposto do que a maioria dos exemplos faz:

  1. <dialog> nativo com showModal(), em vez de div[role=dialog] com hidden e um focus trap escrito à mão. Foco preso, Esc, foco devolvido e inert no resto da página vêm de graça e testados. O preço é reposicionar um elemento que nasce centrado, e isso se resolve em CSS.
  2. Conteúdo em HTML estático, movido para dentro do painel — não clonado, não injetado por JSON, não buscado por fetch. É o que faz a página continuar legível sem JavaScript, que era requisito e não cortesia: perfil de assessor é conteúdo editorial.
  3. O bloco de origem escondido pelo script, na última linha — nunca pelo CSS. A diferença aparece só no dia em que o script não roda, e é a diferença entre uma página inteira e uma página pela metade.

06/09/2026 — três promoções do core adotadas no mesmo dia. Poucas horas depois de o componente entrar, .dialog-casca, LIB.travarRolagem e --veil viraram core, tirados do que quatro sobreposições escreviam igual — e este foi um dos dois que consomem os três de uma vez. Saíram do arquivo local sete declarações de CSS (padding, border e os dois max-* do diálogo; background, backdrop-filter e o par -webkit- do ::backdrop) e o estado de trava por painel do script, que virou uma chamada guardada. Nada mudou na tela: painel 520×900 no desktop e 375×844 no celular, corpo 650px de altura, desfoque do véu blur(7px), entrada em três faixas com 0s/.09s/.18s, foco, Esc, clique no fundo e arrasto idênticos. Mudou de propósito um valor só — o véu, de 74% para 78% —, medido em pixel e registrado nos Gotchas.

Normalizações aplicadas: prefixo drw- em toda classe; .btn-disc do core no lugar de um sexto botão-disco copiado; .glass/.glass-hover, .pill, .btn/.btn-primary, .sec-head, .eyebrow e .nota (as ressalvas) consumidos, não recopiados; entrada em três faixas escrita no próprio componente (o data-rv do core é IntersectionObserver e revela uma vez só, o que não serve a um painel que abre e fecha); @media(prefers-reduced-motion) colado a cada regra que ele desliga.

Divergências conhecidas vs. produção: nenhuma — o componente ainda não rodou num site. O primeiro uso real deve mexer em duas coisas: a largura (--drw-w) contra o comprimento real do texto do cliente, e a troca do monograma por retrato, que pede a autorização de imagem descrita em Compliance.

Verificação (2026-09-06, Chrome headless, bin/medir.js e roteiro próprio): console limpo em 1440×900, 390×844, --reduce e sem JavaScript; zero overflow horizontal nos quatro. Foco entra no botão de fechar ao abrir e volta ao card ao fechar (Esc, botão, clique no fundo e arrasto). Abrir o item 1, fechar e abrir o item 3 entrega o texto do item 3; zero id duplicado com o painel aberto; os quatro blocos voltam ao lugar e à ordem original. Roda do mouse com o painel aberto: scrollY 0 antes e 0 depois; ao fechar, o overflow inline volta a "" e a página não salta. No celular o painel mede 390 de 390. Arrasto horizontal de 40px devolve o painel; de 160px fecha e devolve o bloco; arrasto vertical de 200px não move o painel nem o fecha. Sob --reduce, transition-duration 0s no painel e nas três faixas, e ele continua abrindo e fechando.

Reverificação (2026-09-06, depois de adotar as três promoções do core): o mesmo roteiro rodado antes e depois da troca, em 1440×900, 390×844 e --reduce, comparando 741 valores medidos. Bateram todos, menos os esperados: a classe nova no <dialog>, max-width/max-height do diálogo passando de 100% para none sem que uma única caixa mudasse (painel 520×900, corpo 520×649,8, pé 520×143,4, idênticos ao pixel), o alvo da trava indo do <body> para o <html>, e o véu de 74% para 78%. scrollY e clientWidth iguais nos três momentos da trava, console limpo nos seis cenários, bin/conformidade.py em 0 alertas.