← Biblioteca componentes/overlay/card-expansivel-fullscreen/ficha.md CódigoTela cheia
overlay/card-expansivel-fullscreen

Card que expande para tela cheia

🟢 estavel complexidade alta prefixo exp- overlay modal dialogo cases portfolio trilho flip acessibilidade

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

<link rel="stylesheet" href="componentes/overlay/card-expansivel-fullscreen/estilo.css">
<script src="componentes/overlay/card-expansivel-fullscreen/script.js"></script>

markup.html · 185 linhas · 11,2 KB

<!-- ==========================================================================
     CARD EXPANSÍVEL EM TELA CHEIA — trilho de cards que viram overlay
     Prefixo: exp-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. O crescimento do card até a tela cheia é FLIP em transform,
     encadeado por transitionend — o original usava GSAP e não pôde vir.

     TODO o conteúdo do overlay mora nos data-* do próprio card. Não existe
     um segundo bloco de HTML por case: o overlay é um só, e o script o
     preenche com o que o card carrega. Case novo = mais um <li>, nada além.

     ATENÇÃO AO data-rv (contrato §5): o reveal do core aplica filter:blur()
     no elemento, e filter cria bloco de contenção para descendentes
     position:fixed. O overlay é fixed. Por isso o data-rv está no
     .exp-track e NUNCA no .exp — se subir um nível, o overlay deixa de
     cobrir a tela e passa a viver dentro do trilho.
     ========================================================================== -->

<section class="sec-dark sec-pad" id="trabalhos">
  <div class="wrap">
    <div class="sec-head exp-head">
      <span class="eyebrow" data-rv>Casos atendidos</span>
      <h2 class="sec-title ttl">O trabalho fala <em>por si</em></h2>
      <p class="sec-lead" data-rv data-d="1">Uma frase situando a coleção: o recorte, o período, o critério de entrada. O detalhe de cada caso abre no clique — aqui fora só cabem categoria e título.</p>
    </div>
  </div>

  <!-- Fora do .wrap: o trilho sangra até a borda. O alinhamento da primeira
       foto com a coluna vem do --exp-gutter, calculado com --max e --pad. -->
  <div class="exp" data-exp>

    <ul class="exp-track" data-exp-track data-rv data-d="2">

      <li class="exp-item">
        <button class="exp-card" type="button" data-exp-card aria-haspopup="dialog"
                data-img="../../../showroom/demo/ph-4.webp"
                data-cat="Consultoria"
                data-title="Reestruturação de uma operação familiar"
                data-desc="Texto do caso em 35 a 55 palavras: o que existia antes, a decisão que mudou o rumo e o que ficou de pé no fim. É o único bloco longo do componente — o card, lá fora, carrega só categoria e título."
                data-tags="Diagnóstico,Governança,Sucessão">
          <img class="exp-card-img" src="../../../showroom/demo/ph-4.webp" alt="Três pessoas conversando em escritório com vista para a cidade" loading="lazy" decoding="async">
          <span class="exp-card-info">
            <span class="exp-card-cat">Consultoria</span>
            <span class="exp-card-title">Reestruturação de uma operação familiar</span>
          </span>
          <span class="exp-card-hint glass" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
          </span>
        </button>
      </li>

      <li class="exp-item">
        <button class="exp-card" type="button" data-exp-card aria-haspopup="dialog"
                data-img="../../../showroom/demo/ph-7.webp"
                data-cat="Governança"
                data-title="Um conselho consultivo montado do zero"
                data-desc="Mantenha o mesmo comprimento em todos os casos. A coluna de texto centraliza na vertical, e um caso muito mais curto que os vizinhos desequilibra a página assim que a pessoa passa de um card para o outro."
                data-tags="Conselho,Ritos,Decisão">
          <img class="exp-card-img" src="../../../showroom/demo/ph-7.webp" alt="Reunião de quatro pessoas em sala de vidro" loading="lazy" decoding="async">
          <span class="exp-card-info">
            <span class="exp-card-cat">Governança</span>
            <span class="exp-card-title">Um conselho consultivo montado do zero</span>
          </span>
          <span class="exp-card-hint glass" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
          </span>
        </button>
      </li>

      <li class="exp-item">
        <button class="exp-card" type="button" data-exp-card aria-haspopup="dialog"
                data-img="../../../showroom/demo/ph-6.webp"
                data-cat="Internacional"
                data-title="Patrimônio em três jurisdições"
                data-desc="Escreva no passado e sem jargão interno: quem lê chegou aqui por curiosidade, não por briefing. Se o caso não couber em 60 palavras, ele merece página própria — e então este card vira link para ela."
                data-tags="Estrutura,Conformidade,Câmbio">
          <img class="exp-card-img" src="../../../showroom/demo/ph-6.webp" alt="Globo terrestre antigo sobre mesa de biblioteca" loading="lazy" decoding="async">
          <span class="exp-card-info">
            <span class="exp-card-cat">Internacional</span>
            <span class="exp-card-title">Patrimônio em três jurisdições</span>
          </span>
          <span class="exp-card-hint glass" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
          </span>
        </button>
      </li>

      <li class="exp-item">
        <button class="exp-card" type="button" data-exp-card aria-haspopup="dialog"
                data-img="../../../showroom/demo/ph-8.webp"
                data-cat="Planejamento"
                data-title="O plano que coube em uma página"
                data-desc="No telefone a coluna de texto rola, mas as duas primeiras linhas são o que a maioria vai ler antes de decidir se continua. Ponha nelas a informação que sustenta o caso, não a preparação para ela."
                data-tags="Plano,Metas,Revisão">
          <img class="exp-card-img" src="../../../showroom/demo/ph-8.webp" alt="Mesa de trabalho com xícara de café, caderno e óculos" loading="lazy" decoding="async">
          <span class="exp-card-info">
            <span class="exp-card-cat">Planejamento</span>
            <span class="exp-card-title">O plano que coube em uma página</span>
          </span>
          <span class="exp-card-hint glass" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
          </span>
        </button>
      </li>

      <li class="exp-item">
        <button class="exp-card" type="button" data-exp-card aria-haspopup="dialog"
                data-img="../../../showroom/demo/ph-3.webp"
                data-cat="Sucessão"
                data-title="A conversa que a família adiava"
                data-desc="Evite número que envelhece. Valor, prazo e quantidade pedem uma atualização que ninguém faz, e um caso desatualizado desmente a página inteira. Prefira o que continua verdadeiro daqui a dois anos."
                data-tags="Família,Acordo,Transição">
          <img class="exp-card-img" src="../../../showroom/demo/ph-3.webp" alt="Duas pessoas conversando junto a janela com vista da cidade" loading="lazy" decoding="async">
          <span class="exp-card-info">
            <span class="exp-card-cat">Sucessão</span>
            <span class="exp-card-title">A conversa que a família adiava</span>
          </span>
          <span class="exp-card-hint glass" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
          </span>
        </button>
      </li>

      <li class="exp-item">
        <button class="exp-card" type="button" data-exp-card aria-haspopup="dialog"
                data-img="../../../showroom/demo/ph-1.webp"
                data-cat="Longo prazo"
                data-title="Renda para os próximos trinta anos"
                data-desc="Feche a sequência com o caso de maior alcance. O trilho rola da esquerda para a direita e o último card é o que fica na memória de quem foi até o fim — não desperdice essa posição com o projeto mais fraco."
                data-tags="Renda,Horizonte,Reserva">
          <img class="exp-card-img" src="../../../showroom/demo/ph-1.webp" alt="Casal em varanda com vista para um vilarejo" loading="lazy" decoding="async">
          <span class="exp-card-info">
            <span class="exp-card-cat">Longo prazo</span>
            <span class="exp-card-title">Renda para os próximos trinta anos</span>
          </span>
          <span class="exp-card-hint glass" aria-hidden="true">
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
          </span>
        </button>
      </li>

    </ul>

    <!-- Barra de progresso do trilho. A do sistema fica escondida (suja a
         composição em fundo escuro), então esta devolve a informação de
         "quanto ainda falta". Decorativa: o trilho já é navegável sozinho,
         por Tab e pelo gesto de rolagem lateral. -->
    <div class="wrap">
      <div class="exp-rail" data-exp-rail aria-hidden="true"><span class="exp-rail-thumb"></span></div>
    </div>

    <!-- Um overlay por trilho, vazio no HTML. Quem o preenche é o script,
         a partir dos data-* do card clicado. O id do título e o
         aria-labelledby também são postos pelo script, para não colidirem
         quando houver mais de um trilho na mesma página. -->
    <div class="exp-overlay" data-exp-overlay role="dialog" aria-modal="true" hidden>
      <button class="exp-close glass" type="button" data-exp-close aria-label="Fechar">
        <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
      </button>

      <div class="exp-content" data-exp-content>
        <figure class="exp-media" data-exp-media>
          <!-- sem src no HTML de propósito: um src vazio dispara um pedido
               para a própria página. Quem preenche é o script. -->
          <img class="exp-media-img" data-exp-img alt="">
          <span class="grain" aria-hidden="true"></span>
        </figure>

        <div class="exp-detail" data-exp-detail>
          <div class="exp-detail-body" data-exp-body>
            <span class="eyebrow exp-cat" data-exp-cat></span>
            <h2 class="exp-title" data-exp-title></h2>
            <p class="exp-desc" data-exp-desc></p>
            <div class="exp-tags" data-exp-tags></div>
            <a class="btn btn-primary exp-cta" data-exp-cta href="#contato">
              Falar sobre um caso assim
              <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2 12L12 2M12 2H4M12 2V10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
            </a>
          </div>
        </div>
      </div>
    </div>

  </div>
</section>

estilo.css · 306 linhas · 13,0 KB

/* ==========================================================================
   CARD EXPANSÍVEL EM TELA CHEIA — prefixo exp-
   Trilho horizontal de cards retrato. No clique, o card vira um overlay de
   tela cheia; o conteúdo entra depois que a caixa termina de crescer.

   O crescimento é FLIP: o overlay JÁ nasce do tamanho final (position:fixed
   inset:0) e recebe um transform inverso que o encolhe até o retângulo do
   card. Animar de volta ao transform identidade é o efeito inteiro — e o
   navegador compõe isso na GPU, coisa que top/left/width/height (o que o
   original animava) não permitem.

   O script escreve só cinco variáveis — --exp-tx, --exp-ty, --exp-sx,
   --exp-sy e --exp-r. Quem monta transform e border-radius é este arquivo.
   É o mesmo contrato de canal do midia/galeria-pasta: mudando a fórmula
   aqui, o script continua valendo.

   Tokens: --accent --bg --dark-rgb --display --ease --fg --fg-2 --fg-muted
           --light --max --pad --radius-media --radius-pill --rule --rule-2
   Primitivas do core em uso: .wrap .sec-pad .sec-head .eyebrow .sec-title
           .sec-lead .glass .pill .btn .btn-primary .grain
   ========================================================================== */

/* --dur do core é .9s: certo para entrada de seção, lento demais para uma
   caixa que cresce sob o dedo. As durações de interação são locais e ficam
   aqui, à mão de quem for ajustar. A curva continua sendo a do projeto. */
.exp{
  --exp-dur:.52s;        /* crescimento e recolhimento da caixa */
  --exp-fade:.34s;       /* entrada do conteúdo, depois da caixa */
  --exp-card-w:clamp(200px,24vw,300px);
  --exp-gap:16px;
  --exp-z:500;
  /* Borda da coluna reproduzida fora do .wrap: é o que alinha a primeira
     foto do trilho com o título da seção sem que o trilho entre na coluna
     (contrato §4 — quem precisa sangrar sai do .wrap, não ganha margem
     negativa). Lê os mesmos --max e --pad, então acompanha o tema. */
  --exp-gutter:calc(max(0px,(100% - var(--max)) / 2) + var(--pad));
  position:relative;
  margin-top:clamp(34px,5vh,56px);
}

/* Só o ajuste de largura: o resto vem da primitiva .sec-head do core. */
.exp-head{--head-max:680px}
.exp-head .eyebrow,
.exp-head .sec-title,
.exp-head .sec-lead{margin-inline:auto}

/* ---------------------------------------------------------------- trilho */
/* A barra de rolagem do sistema fica escondida de propósito: em fundo
   escuro ela corta a composição. Em troca, o .exp-rail abaixo devolve a
   informação de quanto ainda falta — esconder sem repor seria esconder. */
.exp-track{
  display:flex;
  gap:var(--exp-gap);
  overflow-x:auto;
  overflow-y:hidden;
  overscroll-behavior-inline:contain;
  padding-inline:var(--exp-gutter);
  /* respiro para o anel de foco do card não ser cortado pelo scrollport */
  padding-block:8px;
  scroll-padding-inline:var(--exp-gutter);
  scrollbar-width:none;
  -ms-overflow-style:none;
  margin:0;
}
.exp-track::-webkit-scrollbar{display:none}

.exp-item{flex:0 0 var(--exp-card-w)}

/* ---------------------------------------------------------------- card */
/* <button> de verdade, não <div> clicável: foco, Enter e Espaço vêm de
   graça, e o aria-haspopup="dialog" avisa que o clique abre um diálogo. */
.exp-card{
  position:relative;display:block;width:100%;aspect-ratio:3/4;
  padding:0;margin:0;background:none;text-align:left;
  color:inherit;font-size:inherit;
  border:1px solid var(--rule);
  border-radius:var(--radius-media);
  overflow:hidden;
  transition:border-color .4s var(--ease);
}
.exp-card:hover{border-color:var(--rule-2)}

.exp-card-img{
  position:absolute;inset:0;width:100%;height:100%;
  object-fit:cover;
  filter:grayscale(.25);
  transition:filter .6s var(--ease);
}
/* HOVER NÃO MEXE NA IMAGEM (CLAUDE.md §6, regra de 2026-09-29). O zoom de
   foto sob o ponteiro saiu: escala e deslocamento de mídia no hover mexem
   em recorte e composição, e é o que quebra a página quando o componente
   é colado noutro lugar. O retorno ficou em cor e brilho, que não mudam
   a geometria de nada. */
.exp-card:hover .exp-card-img,
.exp-card:focus-visible .exp-card-img{filter:grayscale(0)}

/* Texto DENTRO da foto, ancorado embaixo, com véu em gradiente mais blur
   mascarado (contrato §6). Véu em caixa reta lê chapado; a máscara é o que
   dissolve a borda de cima do desfoque. */
.exp-card-info{
  position:absolute;left:0;right:0;bottom:0;z-index:1;
  display:flex;flex-direction:column;gap:7px;
  padding:66px clamp(14px,1.6vw,22px) clamp(15px,1.7vw,24px);
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.92) 0%,
    rgba(var(--dark-rgb),.5) 46%,
    transparent 100%);
  backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
  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%);
}
.exp-card-cat{
  font-size:10.5px;letter-spacing:.15em;text-transform:uppercase;
  color:var(--accent);
}
/* Único ponto do componente que não lê --fg: o véu é --dark-rgb em qualquer
   escopo, então o texto sobre ele tem de ser claro em qualquer escopo. */
.exp-card-title{
  font-family:var(--display);font-weight:700;
  font-size:clamp(16px,1.7vw,22px);letter-spacing:-.025em;line-height:1.14;
  color:var(--light);
}

/* Bolinha de dica no canto. Usa a primitiva .glass do core — borda tênue,
   brilho radial e desfoque já resolvidos lá. As transições do .glass são
   repetidas aqui de propósito, para esta regra não depender da ordem de
   carregamento das folhas (contrato §6.4). */
.exp-card-hint{
  position:absolute;top:13px;right:13px;z-index:2;
  width:34px;height:34px;border-radius:var(--radius-pill);
  display:grid;place-items:center;color:var(--light);
  opacity:0;transform:scale(.8) translateY(-4px);
  transition:opacity .3s var(--ease),transform .3s var(--ease),
             background .45s,border-color .45s;
}
.exp-card:hover .exp-card-hint,
.exp-card:focus-visible .exp-card-hint{opacity:1;transform:none}

/* ---------------------------------------------------------------- barra */
/* --exp-thumb (largura visível / largura total) e --exp-shift (deslocamento
   já resolvido, de 0 a 1) chegam do script. Aparece só quando o trilho
   realmente transborda. */
.exp-rail{
  --exp-thumb:1;--exp-shift:0;
  position:relative;height:2px;
  background:var(--rule);border-radius:var(--radius-pill);
  margin-top:clamp(22px,3.4vh,34px);
  opacity:0;transition:opacity .4s var(--ease);
}
.exp.is-scrollable .exp-rail{opacity:1}
.exp-rail-thumb{
  position:absolute;top:0;bottom:0;
  left:calc(var(--exp-shift) * 100%);
  width:calc(var(--exp-thumb) * 100%);
  background:var(--accent);border-radius:inherit;
}

/* ==========================================================================
   OVERLAY — o card virando tela
   ========================================================================== */

/* O overlay é sempre do tamanho final. O estado inicial é um transform
   inverso escrito pelo script: translate até o canto do card, scale até o
   tamanho do card. Animar até o transform identidade É a expansão.

   O raio precisa da divisão pela escala: um border-radius de 16px dentro de
   uma caixa escalada em .22 sairia com 16 * .22 = 3.5px na tela. Dividindo
   antes, o card começa com o raio exato que tinha, e termina em zero.

   O raio inicial não é constante: o script lê o border-radius do próprio
   card, que é --radius-media. Em tema flat (--radius-media:0) não há raio
   nenhum para animar e o crescimento sai reto — como deve. */
.exp-overlay{
  --exp-tx:0px;--exp-ty:0px;--exp-sx:1;--exp-sy:1;--exp-r:0px;
  position:fixed;inset:0;z-index:var(--exp-z);
  background:var(--bg);
  overflow:hidden;
  transform-origin:0 0;
  transform:translate(var(--exp-tx),var(--exp-ty)) scale(var(--exp-sx),var(--exp-sy));
  border-radius:calc(var(--exp-r) / var(--exp-sx)) / calc(var(--exp-r) / var(--exp-sy));
  transition:transform var(--exp-dur) var(--ease),
             border-radius var(--exp-dur) var(--ease);
}
/* [hidden] é retirado pelo script antes de medir. Existe para o estado
   fechado: sem ele o CTA e o botão de fechar entram na ordem de tabulação
   de uma página que não tem diálogo nenhum aberto. */
.exp-overlay[hidden]{display:none}
.exp-overlay:not([hidden]){will-change:transform}
/* Janela de medição: o script tira o [hidden] para poder medir a caixa e
   só então a encolhe até o card. Sem desligar a transição nesse intervalo,
   o overlay apareceria em tela cheia e a animação rodaria ao contrário. */
.exp-overlay.is-measuring{transition:none}

.exp-close{
  position:absolute;z-index:2;
  top:clamp(16px,2.4vw,26px);right:clamp(16px,2.4vw,26px);
  width:44px;height:44px;padding:0;
  border-radius:var(--radius-pill);
  display:grid;place-items:center;color:var(--fg);
  opacity:0;transform:scale(.9);
  transition:opacity .3s var(--ease),transform .3s var(--ease),
             background .45s,border-color .45s;
}
.exp-overlay.is-shown .exp-close{opacity:1;transform:none;transition-delay:.06s}

.exp-content{
  position:absolute;inset:0;
  display:grid;grid-template-columns:1fr 1fr;
  opacity:0;
  transition:opacity var(--exp-fade) var(--ease);
}
.exp-overlay.is-shown .exp-content{opacity:1}

.exp-media{position:relative;overflow:hidden;margin:0}
.exp-media-img{
  width:100%;height:100%;object-fit:cover;
  transform:scale(1.05);
  transition:transform 1.1s var(--ease);
}
.exp-overlay.is-shown .exp-media-img{transform:none}

.exp-detail{
  display:flex;flex-direction:column;justify-content:center;
  padding:clamp(36px,6vw,72px) var(--pad);
  overflow-y:auto;
}

/* Nada entra seco (contrato §5): o texto sobe com blur indo a zero, um
   tempo depois da imagem. O blur mora aqui, e não no .exp-content inteiro,
   porque desfocar a foto de tela cheia custa caro e não acrescenta nada. */
.exp-detail-body{
  opacity:0;filter:blur(12px);transform:translateY(16px);
  transition:opacity var(--exp-fade) var(--ease),
             filter calc(var(--exp-fade) + .2s) var(--ease),
             transform var(--exp-fade) var(--ease);
}
.exp-overlay.is-shown .exp-detail-body{
  opacity:1;filter:blur(0);transform:none;transition-delay:.1s;
}

/* A primitiva .eyebrow é inline-flex; aqui ela precisa ocupar a própria
   linha para a margem de baixo valer. Só o display muda — traço, tracking
   e cor continuam sendo os do core. */
.exp-cat{display:flex;width:fit-content;margin-bottom:16px}
.exp-title{
  font-family:var(--display);font-weight:700;
  font-size:clamp(28px,3.6vw,50px);letter-spacing:-.035em;line-height:1.02;
  color:var(--fg);max-width:16ch;
}
.exp-desc{
  color:var(--fg-2);line-height:1.72;max-width:44ch;margin-top:22px;
}
.exp-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:30px}
.exp-cta{margin-top:34px;align-self:flex-start}

/* [hidden] entra pelo script quando o slot do card vem vazio. Precisa ser
   dito onde o componente declarou um display: a regra do agente do usuário
   perde para qualquer regra de autor, e o slot vazio continuaria ocupando
   espaço — no caso do .exp-cat, com o traço do olho e nada depois dele. */
.exp-cat[hidden],
.exp-tags[hidden]{display:none}

/* ---------------------------------------------------------------- movimento reduzido */
/* Quem pediu menos movimento não vê o FLIP: o script pula a medição e o
   overlay simplesmente aparece inteiro. As durações caem para quase zero em
   vez de virarem "none" — assim o transitionend continua disparando e o
   encadeamento do script segue funcionando por um caminho só. */
@media(prefers-reduced-motion:reduce){
  .exp-overlay,.exp-content,.exp-detail-body,.exp-close,
  .exp-media-img,.exp-card,.exp-card-img,.exp-card-hint,.exp-rail{
    transition-duration:.01ms;transition-delay:0s;
  }
  .exp-media-img{transform:none}
  .exp-detail-body{filter:none;transform:none}
}

/* ---------------------------------------------------------------- mobile */
/* Duas colunas de 50% em tela estreita dão 180px para a foto e 180px para um
   parágrafo de 45 palavras: ilegível dos dois lados. Vira empilhado, com a
   foto em faixa e o texto rolando embaixo. Era o que faltava no original —
   o grid-template-columns:1fr 1fr valia até em telefone. */
@media(max-width:900px){
  .exp-content{
    grid-template-columns:1fr;
    grid-template-rows:minmax(0,34vh) minmax(0,1fr);
  }
  .exp-title{font-size:clamp(26px,6.4vw,38px);max-width:none}
  .exp-desc{max-width:none;margin-top:18px}
  .exp-detail{padding:clamp(26px,6vw,40px) var(--pad)}
  .exp-tags{margin-top:22px}
  .exp-cta{margin-top:26px}
}

@media(max-width:560px){
  .exp{--exp-card-w:clamp(180px,62vw,240px);--exp-gap:12px}
  .exp-close{width:40px;height:40px;top:14px;right:14px}
  .exp-content{grid-template-rows:minmax(0,30vh) minmax(0,1fr)}
  .exp-card-hint{width:30px;height:30px;top:11px;right:11px}
}

/* Telefone deitado: 30vh de foto não sobra texto nenhum. A foto sai e a
   coluna de texto fica com a tela inteira. */
@media(max-height:520px) and (orientation:landscape){
  .exp-content{grid-template-columns:1fr;grid-template-rows:1fr}
  .exp-media{display:none}
}

script.js · 314 linhas · 13,4 KB

/* ==========================================================================
   CARD EXPANSÍVEL EM TELA CHEIA — abrir, fechar e a barra do trilho.

   A expansão é um FLIP feito à mão, sem lib:
     1. o overlay já existe do tamanho final (fixed, inset:0);
     2. mede-se o retângulo do card com getBoundingClientRect();
     3. escreve-se o transform INVERSO que encolhe o overlay até esse
        retângulo — ainda com [hidden], para não haver quadro nenhum
        em que a caixa apareça no tamanho errado;
     4. tira-se o [hidden] e força-se um reflow: esse é o estado inicial;
     5. apagam-se as variáveis. O transform volta à identidade e a
        transição do CSS faz o resto.

   O script escreve só --exp-tx/--exp-ty/--exp-sx/--exp-sy/--exp-r. Quem
   monta transform e border-radius é o estilo.css — mudar a fórmula lá não
   exige tocar aqui. Nenhum `style.transform` sai deste arquivo.

   Todo o conteúdo do overlay vem dos data-* do card. As tags são criadas
   com createElement + textContent: o original montava HTML concatenando
   um atributo, o que injeta marcação em quem escrever `<` no CMS.
   ========================================================================== */

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

  var ESPERA = 900;      /* rede de segurança se o transitionend não vier */
  var THUMB_MIN = .12;   /* polegar mínimo da barra, em fração do trilho */

  /* Flag do core (contrato §7.5), com plano B para o caso de o motion.js não
     ter entrado na página. Só a FLAG tem plano B: a trava de rolagem agora é
     LIB.travarRolagem(), e essa não tem — o componente.json declara o
     motion.js, e o contador compartilhado é o único jeito de não brigar com
     as outras sobreposições da página. */
  function reduzido() {
    if (window.LIB && typeof LIB.reduce === 'boolean') return LIB.reduce;
    return matchMedia('(prefers-reduced-motion: reduce)').matches &&
           location.search.indexOf('motion=on') < 0;
  }

  /* --------------------------------------------------- fim de uma transição
     transitionend borbulha: filtra por alvo e por propriedade, senão a
     transição de um filho encerra o passo do pai. O setTimeout é rede —
     transição cancelada no meio nunca dispara o evento, e sem ele o
     overlay ficaria preso, aberto e sem conteúdo. */
  function aoFim(el, prop, fn) {
    if (reduzido()) { fn(); return; }
    var feito = false, prazo;
    function acabar(e) {
      if (e && (e.target !== el || e.propertyName !== prop)) return;
      if (feito) return;
      feito = true;
      clearTimeout(prazo);
      el.removeEventListener('transitionend', acabar);
      fn();
    }
    prazo = setTimeout(acabar, ESPERA);
    el.addEventListener('transitionend', acabar);
  }

  var seq = 0;

  /* ====================================================================== */
  function iniciar(raiz) {
    var trilho  = raiz.querySelector('[data-exp-track]');
    var overlay = raiz.querySelector('[data-exp-overlay]');
    var cards   = raiz.querySelectorAll('[data-exp-card]');
    if (!trilho || !overlay || !cards.length) return;

    var barra    = raiz.querySelector('[data-exp-rail]');
    var conteudo = overlay.querySelector('[data-exp-content]');
    var detalhe  = overlay.querySelector('[data-exp-detail]');
    var btnFecha = overlay.querySelector('[data-exp-close]');
    var elImg    = overlay.querySelector('[data-exp-img]');
    var elCat    = overlay.querySelector('[data-exp-cat]');
    var elTit    = overlay.querySelector('[data-exp-title]');
    var elDesc   = overlay.querySelector('[data-exp-desc]');
    var elTags   = overlay.querySelector('[data-exp-tags]');
    var elCta    = overlay.querySelector('[data-exp-cta]');
    var ctaBase  = elCta ? elCta.getAttribute('href') : null;
    if (!conteudo) return;

    /* Rótulo do diálogo. O id é gerado porque o mesmo markup pode aparecer
       duas vezes na página, e dois id iguais quebrariam o aria-labelledby
       dos dois. */
    if (elTit) {
      if (!elTit.id) elTit.id = 'exp-title-' + (++seq);
      overlay.setAttribute('aria-labelledby', elTit.id);
    }

    var origem = null;    /* card que abriu — é para ele que o foco volta */
    var aberto = false;
    var travado = false;

    /* ------------------------------------------------------ preenchimento */
    function texto(el, valor) {
      if (!el) return;
      el.textContent = valor || '';
      el.hidden = !valor;
    }

    function preencher(card) {
      var foto = card.querySelector('img');
      var src  = card.getAttribute('data-img') || (foto ? foto.getAttribute('src') : '');
      var alt  = card.getAttribute('data-alt') || (foto ? foto.getAttribute('alt') : '');
      if (elImg) { elImg.setAttribute('src', src); elImg.setAttribute('alt', alt); }

      texto(elCat,  card.getAttribute('data-cat'));
      texto(elTit,  card.getAttribute('data-title'));
      texto(elDesc, card.getAttribute('data-desc'));

      if (elTags) {
        elTags.textContent = '';
        var lista = (card.getAttribute('data-tags') || '').split(',');
        for (var i = 0; i < lista.length; i++) {
          var t = lista[i].replace(/^\s+|\s+$/g, '');
          if (!t) continue;
          var s = document.createElement('span');
          s.className = 'pill';        /* primitiva do core, não uma pílula nova */
          s.textContent = t;           /* nunca innerHTML: o texto vem de atributo */
          elTags.appendChild(s);
        }
        elTags.hidden = !elTags.firstChild;
      }

      if (elCta) elCta.setAttribute('href', card.getAttribute('data-href') || ctaBase || '#');
    }

    /* ------------------------------------------------------------- o FLIP */
    /* A escala é a razão entre o card e o TAMANHO DO PRÓPRIO OVERLAY, nunca
       o clientWidth do documento: com o scrollbar-gutter:stable do
       core/base.css o bloco de contenção de um position:fixed é menor que o
       clientWidth, e a caixa nasceria alguns pixels fora do card. Medir o
       elemento que vai animar não tem como divergir dele mesmo. */
    function medida(card, lw, lh) {
      var r = card.getBoundingClientRect();
      return {
        tx: r.left,
        ty: r.top,
        /* nunca zero: a escala divide o raio no CSS */
        sx: Math.max(r.width  / lw, .001),
        sy: Math.max(r.height / lh, .001),
        /* o raio inicial é o do próprio card, que lê --radius-media.
           Em tema flat dá 0 e não há raio nenhum para animar. */
        r : parseFloat(getComputedStyle(card).borderTopLeftRadius) || 0
      };
    }

    function aplicar(m) {
      overlay.style.setProperty('--exp-tx', m.tx + 'px');
      overlay.style.setProperty('--exp-ty', m.ty + 'px');
      overlay.style.setProperty('--exp-sx', m.sx);
      overlay.style.setProperty('--exp-sy', m.sy);
      overlay.style.setProperty('--exp-r',  m.r + 'px');
    }

    function limpar() {
      overlay.style.removeProperty('--exp-tx');
      overlay.style.removeProperty('--exp-ty');
      overlay.style.removeProperty('--exp-sx');
      overlay.style.removeProperty('--exp-sy');
      overlay.style.removeProperty('--exp-r');
    }

    /* ------------------------------------------------------------- abrir */
    function abrir(card) {
      if (aberto) return;
      aberto = true;
      origem = card;
      preencher(card);

      /* Trava ANTES de medir: a ordem é a do componente, não do core, e fica.
         A trava do core não mexe no layout — a calha do scrollbar-gutter
         continua reservada e nada se desloca (medido: ficha) —, mas medir um
         retângulo antes de mudar o estado da página é a ordem certa de
         qualquer jeito, e é grátis. */
      if (!travado) { travado = true; LIB.travarRolagem(); }

      /* Quatro recálculos de estilo, nesta ordem, e cada um tem um porquê.
         O .is-measuring desliga a transição enquanto o overlay está
         visível mas ainda não posicionado — sem ele, o recálculo que mede
         a caixa já a mostra em tela cheia e o efeito roda ao contrário. */
      overlay.classList.add('is-measuring');
      overlay.hidden = false;
      var lw = overlay.offsetWidth;    /* 1: mede — identidade, sem transição */
      var lh = overlay.offsetHeight;
      if (!reduzido()) aplicar(medida(card, lw, lh));
      void overlay.offsetWidth;        /* 2: fixa o estado inicial, no card */
      overlay.classList.remove('is-measuring');
      void overlay.offsetWidth;        /* 3: religa a transição, sem mexer na caixa */
      limpar();                        /* 4: volta à identidade -> anima */

      if (btnFecha) btnFecha.focus();

      /* O conteúdo só entra quando a caixa terminou de crescer. */
      aoFim(overlay, 'transform', function () {
        if (aberto) overlay.classList.add('is-shown');
      });
    }

    /* ------------------------------------------------------------ fechar */
    function encerrar() {
      if (!aberto) return;
      aberto = false;
      overlay.classList.remove('is-shown');

      /* O conteúdo sai primeiro e a caixa recolhe depois: o inverso exato
         da abertura. E ela recolhe medindo o card DE NOVO — a janela pode
         ter mudado de tamanho enquanto o overlay estava aberto. */
      aoFim(conteudo, 'opacity', function () {
        if (aberto) return;
        if (!reduzido() && origem) {
          aplicar(medida(origem, overlay.offsetWidth, overlay.offsetHeight));
        }

        aoFim(overlay, 'transform', function () {
          if (aberto) return;
          overlay.hidden = true;
          limpar();
          if (travado) { travado = false; LIB.soltarRolagem(); }
          if (origem) { origem.focus(); origem = null; }
        });
      });
    }

    /* -------------------------------------------------------- os gatilhos */
    function ligarCard(card) {
      card.addEventListener('click', function () { abrir(card); });

      /* Pré-carrega a foto grande no primeiro contato. Sem isso, um
         data-img diferente da foto do card chega em branco no meio da
         expansão, que é justamente onde o olho está. */
      var pedido = false;
      function adiantar() {
        if (pedido) return;
        pedido = true;
        var src  = card.getAttribute('data-img');
        var foto = card.querySelector('img');
        if (!src || (foto && src === foto.getAttribute('src'))) return;
        var pre = new Image();
        pre.src = src;
      }
      card.addEventListener('pointerenter', adiantar, { passive: true });
      card.addEventListener('focus', adiantar);
    }

    for (var i = 0; i < cards.length; i++) ligarCard(cards[i]);

    if (btnFecha) btnFecha.addEventListener('click', encerrar);

    /* Clique no fundo. Só vale quando o alvo É uma das três caixas de
       fundo — overlay, grade ou coluna de texto. Comparação por identidade,
       não por "está fora do conteúdo": assim clicar no parágrafo, na foto
       ou no CTA nunca fecha por engano. O original comparava só com o
       overlay, que fica coberto pela grade e nunca é o alvo — ou seja, o
       fechar-clicando-fora dele não fechava nada. */
    overlay.addEventListener('click', function (e) {
      if (!overlay.classList.contains('is-shown')) return;
      if (e.target === overlay || e.target === conteudo || e.target === detalhe) encerrar();
    });

    document.addEventListener('keydown', function (e) {
      if (e.key === 'Escape' && aberto) encerrar();
    });

    /* Foco preso: com aria-modal="true" o resto da página some para o leitor
       de tela, então o Tab não pode continuar passeando por ela. */
    overlay.addEventListener('keydown', function (e) {
      if (e.key !== 'Tab') return;
      var f = overlay.querySelectorAll(
        'a[href],button:not([disabled]),[tabindex]:not([tabindex="-1"])');
      if (!f.length) return;
      var primeiro = f[0], ultimo = f[f.length - 1];
      if (e.shiftKey && document.activeElement === primeiro) {
        e.preventDefault(); ultimo.focus();
      } else if (!e.shiftKey && document.activeElement === ultimo) {
        e.preventDefault(); primeiro.focus();
      }
    });

    /* ------------------------------------------------- barra do trilho */
    /* A barra do sistema está escondida no CSS; esta devolve a informação.
       Escreve duas frações e deixa a conta de largura e posição no CSS. */
    var quadro = 0;

    function pintar() {
      quadro = 0;
      var total = trilho.scrollWidth;
      var vista = trilho.clientWidth;
      var resto = total - vista;
      var rola  = resto > 4;
      raiz.classList.toggle('is-scrollable', rola);
      if (!barra || !rola) return;
      var thumb = Math.max(vista / total, THUMB_MIN);
      var prog  = Math.min(Math.max(trilho.scrollLeft / resto, 0), 1);
      barra.style.setProperty('--exp-thumb', thumb);
      barra.style.setProperty('--exp-shift', prog * (1 - thumb));
    }

    /* Preso ao scroll: pinta no quadro, nunca dentro do listener. */
    function pedirQuadro() {
      if (!quadro) quadro = requestAnimationFrame(pintar);
    }

    trilho.addEventListener('scroll', pedirQuadro, { passive: true });
    addEventListener('resize', pedirQuadro, { passive: true });
    addEventListener('load', pedirQuadro, { passive: true });
    /* As fotos são lazy: o scrollWidth só se acomoda depois que elas chegam. */
    if (window.ResizeObserver) new ResizeObserver(pedirQuadro).observe(trilho);
    pintar();
  }

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

Um trilho horizontal de cards retrato: foto, categoria em cor de marca e título dentro da imagem. No clique, o card não abre um modal centralizado — ele cresce do lugar exato onde está até cobrir a tela, com o raio da quina indo a zero, e só quando termina de crescer o conteúdo entra: foto cheia de um lado, categoria, título, texto, tags e CTA do outro.

O que resolve editorialmente: uma coleção de casos em que cada item merece meia página de leitura, mas nenhum merece uma página inteira. A grade comum obriga a escolher entre mostrar tudo (e virar parede de texto) ou mostrar só o título (e não convencer ninguém). Aqui o card mostra o mínimo, o overlay mostra o suficiente, e a transição entre os dois deixa claro que é o mesmo item — que é a coisa que um modal centralizado nunca consegue dizer.

É o primeiro componente da categoria sobreposições.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho (.eyebrow) 1 a 2 palavras. O tipo de coleção: "Trabalhos", "Casos", "Portfólio".
Título (.sec-title) 1 linha, uma palavra em <em>. Nomeia o conjunto, não convida ao clique: "O trabalho fala por si".
Apoio (.sec-lead) 1 parágrafo, 20 a 35 palavras. Diz o critério de entrada da coleção — por que estes casos e não outros.
data-cat 1 a 2 palavras, a mesma taxonomia em todos os cards. Categorias soltas ("Branding", "Projeto especial", "Outros") denunciam que não existe critério.
data-title 4 a 8 palavras. Nomeia o que aconteceu, não o cliente: "A conversa que a família adiava" vale mais que o nome da empresa — e não expõe cliente nenhum.
data-desc 35 a 55 palavras, em um parágrafo só. Passado, sem jargão interno. Todos os cards com comprimento parecido: a coluna centraliza na vertical, e um texto muito mais curto que os vizinhos desequilibra a troca de um card para o outro.
data-tags 3 a 4 rótulos, 1 a 2 palavras cada, separados por vírgula. São frentes de trabalho, não adjetivos.
alt da foto Obrigatório e descritivo. É o único conteúdo que chega a quem não vê a imagem — e ele reaparece no overlay.
CTA Um só, o mesmo para todos os cards, no markup. Só use data-href por card quando cada caso tiver mesmo um destino diferente.

Use de 4 a 10 cards. Com 3 o trilho não transborda em desktop, a barra de progresso some sozinha e o gesto de rolar lateralmente nunca acontece — a seção fica prometendo um movimento que não existe. Acima de 10, ninguém chega ao fim.


Especificação das imagens

Proporção retrato, 3:4 — a caixa do card é aspect-ratio:3/4
Mínimo 900×1200. No overlay a foto ocupa metade da tela em altura cheia
Recorte object-fit:cover nos dois lugares. O assunto tem de sobreviver a corte lateral no card e a corte vertical no overlay
Peso com loading="lazy", só as visíveis carregam de início; ainda assim, abaixo de 250 KB cada
Tom fotos de contraste médio para escuro. O card recebe grayscale(.25) em repouso e volta ao normal no hover — foto já dessaturada não tem para onde ir

data-img existe para apontar uma versão maior da mesma foto: o card pode carregar um recorte leve e o overlay a imagem cheia. Quando as duas são a mesma, pode omitir — o script cai na foto do card. O script pré-carrega a versão grande no primeiro hover ou foco do card, então ela chega antes da animação terminar.


Como usar

<link rel="stylesheet" href="componentes/overlay/card-expansivel-fullscreen/estilo.css">
<script src="componentes/overlay/card-expansivel-fullscreen/script.js"></script>

Cole markup.html, estilo.css e script.js. Depende do core (tokens, base, motion) e de mais nada.

Card novo é só mais um <li>. Todo o conteúdo do overlay mora nos data-* do card; não existe um segundo bloco de HTML por caso, e não há nada para manter em sincronia.

<li class="exp-item">
  <button class="exp-card" type="button" data-exp-card aria-haspopup="dialog"
          data-img="img/caso-1-grande.webp"
          data-cat="Consultoria"
          data-title="Título do caso"
          data-desc="Parágrafo de 35 a 55 palavras."
          data-tags="Uma,Duas,Três">
    <img class="exp-card-img" src="img/caso-1.webp" alt="Descrição da foto" loading="lazy" decoding="async">
    ...
  </button>
</li>

Ganchos

Gancho Papel
[data-exp] a raiz. O script aceita vários trilhos na mesma página, cada um com o seu overlay
[data-exp-track] o trilho que rola. É aqui que vai o data-rv, nunca no .exp
[data-exp-card] o card. Um <button> de verdade, com aria-haspopup="dialog"
[data-exp-overlay] o diálogo. role="dialog", aria-modal="true", hidden no repouso
[data-exp-close], [data-exp-rail] botão de fechar e barra de progresso do trilho
[data-exp-img|cat|title|desc|tags|cta] os destinos dentro do overlay. O script preenche
.exp-overlay.is-shown conteúdo visível — posto depois que a caixa terminou de crescer
.exp.is-scrollable o trilho transborda. Só aí a barra aparece
.exp-overlay.is-measuring janela de medição, com a transição desligada. Estado interno do script
--exp-tx/ty/sx/sy/r o canal do FLIP: o script escreve, o CSS monta transform e border-radius
--exp-thumb, --exp-shift o canal da barra, em frações de 0 a 1
--exp-dur, --exp-fade duração da caixa e do conteúdo
--exp-card-w, --exp-gap, --exp-z largura do card, vão do trilho e camada do overlay

Atributos do card

Atributo Obrigatório O quê
data-cat sim categoria; vira o olho do overlay
data-title sim título; vira o rótulo acessível do diálogo
data-desc sim o parágrafo
data-tags não lista separada por vírgula; vira .pill do core. Ausente, o bloco some
data-img não versão maior da foto. Ausente, usa a foto do card
data-alt não alt da versão maior. Ausente, usa o alt da foto do card
data-href não destino do CTA só para este card. Ausente, usa o href do markup

Variações

Variação Como
Quantidade de cards 4 a 10. Abaixo de 4 a barra some e o trilho não rola
Card sem overlay tire o data-exp-card e troque o <button> por <a href>. O card vira link para a página do caso
Largura do card --exp-card-w no .exp
Ritmo --exp-dur (.52s) e --exp-fade (.34s) no .exp
Camada --exp-z (500). Suba se o menu fixo do projeto passar disso

Gotchas


Histórico

Projeto Onde Data Diferenças
Gabriel Rocha Studio gabriel-rocha-studio-2.html, #cases 2026-08 Origem. Site autoral do próprio estúdio, rodou uma vez. GSAP com timeline, <div> clicável, top/left/width/height animados, innerHTML nas tags, sem role="dialog", sem media query própria.

Calibragem no primeiro uso. Foi bastante reescrito na travessia, e a abertura a partir da posição exata do card é a parte sensível: espere ajuste se o trilho mudar de altura.

No original Aqui Por quê
GSAP timeline com power3.inOut FLIP em transform, encadeado por transitionend O contrato só admite GSAP para timeline encadeada de verdade ou ScrollTrigger com scrub. Duas etapas em sequência resolvem-se com dois transitionend
Animava top, left, width, height translate() + scale() com transform-origin:0 0 Aquelas quatro propriedades disparam layout a cada quadro; transform é composto na GPU. A expansão passa a ser barata mesmo em telefone
borderRadius:16 cru --radius-media lido do card, dividido pela escala Cor, forma e raio vêm do tema. E sem a divisão o raio sairia esmagado junto com a caixa
<div class="exp-card"> com listener de clique <button type="button"> com aria-haspopup="dialog" Foco, Enter e Espaço de graça; e o card entra na ordem de tabulação sem tabindex inventado
Sem role, sem aria-modal, sem foco preso, sem devolução de foco role="dialog", aria-modal="true", aria-labelledby com id gerado, Tab preso e foco de volta ao card Solução do navegacao/drawer-fullscreen, com o foco preso que faltava lá — aqui é modal de verdade
document.body.style.overflow='hidden' e depois '' LIB.travarRolagem() / LIB.soltarRolagem() do core O original apagava qualquer overflow que a página já tivesse e deixava a página saltar para a direita na abertura. A correção nasceu aqui — contador, valor anterior guardado, compensação de largura medida — e em 2026-09 subiu para o core/motion.js, onde as quatro sobreposições da biblioteca passam a compartilhar o mesmo contador
expTags.innerHTML = ...split(',').map('<span…>') createElement + textContent Montar HTML concatenando um atributo injeta marcação assim que alguém escrever < no CMS
.exp-content{grid-template-columns:1fr 1fr} sem media query empilha abaixo de 900px; foto some em paisagem baixa No original, telefone recebia duas colunas de 180px: foto ilegível de um lado, parágrafo de 45 palavras do outro
Fechar clicando fora comparava só com o overlay compara com overlay, grade e coluna de texto A grade cobre o overlay inteiro, então o alvo era sempre um filho: o clique-fora do original nunca fechava nada
stroke="white" e stroke="#fff" nos SVG stroke="currentColor" Ícone tem de virar junto com o tema
Barra de rolagem escondida, sem nada no lugar escondida + .exp-rail com progresso A informação de "ainda tem mais para o lado" tinha sumido junto com a barra
.exp-card-overlay (gradiente) + .exp-card-info (texto) um bloco só, com gradiente, backdrop-filter e máscara O véu do contrato §6 pede máscara em gradiente; e duas caixas empilhadas para o mesmo efeito viravam duas camadas de composição por card
Cores cruas (rgba(6,7,10,.88), #3a3a36, rgba(255,255,255,.1)) --dark-rgb, --rule-2, primitiva .glass O mesmo componente tem de rodar em qualquer tema
.exp-tag com borda, raio e tracking próprios primitiva .pill do core Era a terceira cópia da mesma pílula na biblioteca (contrato §6)
.cases-head com h2 e .eyebrow próprios .sec-head + h2.sec-title + .sec-lead do core Cabeçalho de dobra já é primitiva
Sem tratamento de movimento reduzido FLIP desligado, durações a .01ms, hover sem scale Exigência do contrato §5
Fotos por caminho de imgs/ do site placeholders locais do showroom Conteúdo de cliente não entra na biblioteca
Nomes de projeto inventados no demo slots descritivos e genéricos Demo da biblioteca não nomeia marca nem pessoa