← Biblioteca componentes/midia/grade-cases-foto/ficha.md CódigoTela cheia
midia/grade-cases-foto

Grade de cases com foto

🟢 estavel complexidade baixa prefixo gcs- grade cases foto 4-colunas portfolio link legenda-na-foto veu-mascarado prova-social css-puro sem-js

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

<link rel="stylesheet" href="componentes/midia/grade-cases-foto/estilo.css">
<script src="componentes/midia/grade-cases-foto/script.js"></script>

markup.html · 123 linhas · 6,4 KB

<!-- ==========================================================================
     GRADE DE CASES COM FOTO — quatro retratos em fileira, cada um um link
     Prefixo: gcs-
     Requer: core (tokens, base, motion.css, motion.js) + este estilo.css
     SEM JAVASCRIPT — nem uma linha na grade-padrão. O script.js da pasta
     só serve à variação "ver mais (+N)" e sai no guard sem ela.

     A IDEIA. É a grade que o trilho não sabe ser. A biblioteca já tem cases
     com foto em faixa horizontal (midia/galeria-scroll-horizontal), e essa
     faixa esconde de propósito o que está fora da tela: mostra dois ou três
     e promete o resto. Aqui os quatro estão à vista de uma vez, e isso muda a
     frase que a dobra diz — de "explore o portfólio" para "é isto, são estas
     quatro". Para uma assessoria, quatro frentes de trabalho não são uma
     coleção aberta que se navega: são o escopo declarado. O que a faixa cobra
     em gesto e em 141 linhas de script (setas, Home/End, dois botões, dica),
     esta grade não cobra de ninguém — o card é um <a> na ordem do documento,
     o Tab passa nos quatro e o anel de foco é o do core.

     O CARD INTEIRO É O LINK, e isto é o <a> de verdade, não um ::after
     esticado por cima de um <article>. A diferença aparece no anel de foco:
     sendo o <a> a própria caixa do card, o :focus-visible do core desenha o
     anel em volta do CARD, que é a área que o clique acerta. O custo é o
     mesmo do prova/manchetes-imprensa e está na ficha: não dá para selecionar
     o texto do card com o mouse. Aqui o texto é uma categoria de uma palavra
     e um título de cinco — ninguém seleciona isso.

     O <h3> DENTRO DO <a> É DELIBERADO. É válido (o <a> tem modelo de conteúdo
     transparente) e paga duas coisas: os quatro títulos entram no sumário de
     cabeçalhos, que é como um leitor de tela varre a página, e o card continua
     sendo um alvo só. Não troque o <h3> por <span>: o sumário some e nada
     acusa.

     TEXTO DENTRO DA FOTO (contrato §6). Categoria e título ficam ancorados no
     rodapé da imagem, sobre gradiente mais véu desfocado com máscara — a
     receita do conteudo/pilares-texto-na-foto, que é a da casa. Nunca embaixo
     da foto: legenda embaixo transforma uma peça gráfica em duas, e faz a
     linha de base dançar entre um card de título curto e um de título longo.
     São duas camadas no markup e não três: o véu desfocado é um ::before da
     própria legenda, para medir a ALTURA DELA e nunca ficar atrás de um título
     de três linhas. O estilo.css explica a conta.

     PROPORÇÃO ÚNICA, RESOLVIDA NO CSS. Todas as células são 3:4 por
     aspect-ratio + object-fit:cover. O consumidor NÃO precisa recortar as
     fotos no mesmo tamanho — e não deve: o enquadramento se ajusta por
     --gcs-pos no style de cada card, como no pilares. Foto de proporção
     diferente na mesma fileira é o que faz uma grade parecer amadora, e isso
     não é problema de quem entrega a foto.

     O CONTEÚDO É ILUSTRATIVO e fala a voz de uma assessoria: cada card nomeia
     uma SITUAÇÃO e o trabalho que ela pede, nunca o resultado do cliente e
     nunca o nome dele. Ver a seção Compliance da ficha antes de publicar.
     ========================================================================== -->

<section class="sec-dark sec-pad gcs">
  <div class="wrap">

    <span class="eyebrow" data-rv>Situações recorrentes</span>
    <h2 class="sec-title ttl">Onde o trabalho <em>começa</em></h2>
    <p class="sec-lead" data-rv data-d="1">As quatro situações que mais aparecem na primeira conversa. Cada uma leva à página em que o trabalho está descrito por inteiro.</p>

    <!-- Lista de verdade: o leitor de tela anuncia "4 itens" antes do primeiro
         card. O data-rv vai no <li>, nunca no <a> — o card gerencia transform
         próprio no ::after, e [data-rv].in{transform:none} vale (0,2,0) e
         apagaria o que estivesse no mesmo nó (contrato §5). O data-d escalona
         a entrada da esquerda para a direita, que é a ordem de leitura da
         fileira. -->
    <ul class="gcs-grid">

      <li class="gcs-item" data-rv>
        <a class="gcs-card" href="#situacao-sucessao" style="--gcs-pos:50% 40%">
          <img class="gcs-img" src="../../../showroom/demo/ph-5.webp"
               alt="Homem mais velho e criança lendo lado a lado"
               width="900" height="1200" loading="lazy" decoding="async">
          <span class="gcs-scrim"></span>
          <div class="gcs-cap">
            <span class="gcs-cat">Sucessão</span>
            <h3 class="gcs-title">Empresa familiar na terceira geração</h3>
          </div>
        </a>
      </li>

      <li class="gcs-item" data-rv data-d="1">
        <a class="gcs-card" href="#situacao-consolidacao" style="--gcs-pos:56% 44%">
          <img class="gcs-img" src="../../../showroom/demo/ph-4.webp"
               alt="Três pessoas reunidas em escritório com vista para a cidade"
               width="900" height="1200" loading="lazy" decoding="async">
          <span class="gcs-scrim"></span>
          <div class="gcs-cap">
            <span class="gcs-cat">Consolidação</span>
            <h3 class="gcs-title">Carteira dividida em quatro bancos</h3>
          </div>
        </a>
      </li>

      <li class="gcs-item" data-rv data-d="2">
        <a class="gcs-card" href="#situacao-liquidez" style="--gcs-pos:50% 40%">
          <img class="gcs-img" src="../../../showroom/demo/ph-3.webp"
               alt="Duas pessoas conversando junto a uma janela alta"
               width="900" height="1200" loading="lazy" decoding="async">
          <span class="gcs-scrim"></span>
          <div class="gcs-cap">
            <span class="gcs-cat">Liquidez</span>
            <h3 class="gcs-title">Saída de um sócio da empresa</h3>
          </div>
        </a>
      </li>

      <li class="gcs-item" data-rv data-d="3">
        <a class="gcs-card" href="#situacao-internacional" style="--gcs-pos:50% 42%">
          <img class="gcs-img" src="../../../showroom/demo/ph-6.webp"
               alt="Globo terrestre antigo em uma biblioteca"
               width="900" height="1200" loading="lazy" decoding="async">
          <span class="gcs-scrim"></span>
          <div class="gcs-cap">
            <span class="gcs-cat">Internacional</span>
            <h3 class="gcs-title">Contas e imóveis em dois países</h3>
          </div>
        </a>
      </li>

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

estilo.css · 294 linhas · 14,0 KB

/* ==========================================================================
   GRADE DE CASES COM FOTO — prefixo gcs-
   Quatro retratos em fileira; cada card é um <a> e cada legenda vive DENTRO
   da foto. CSS puro: nenhuma linha de JavaScript, nenhum observer próprio
   (o script.js da pasta só serve à variação "ver mais", no fim do arquivo).

   Tokens usados:
     --display --sans     tipografia do título e da categoria
     --ease               curva única do projeto
     --accent             categoria e o filete que acende no hover
     --light              texto sobre a foto (ilha escura, ver abaixo)
     --dark-rgb           degradê do véu da legenda
     --radius-media       canto do card
     --rule               borda de repouso do card
     --bg                 fundo do card enquanto a foto não pinta

   O CARD É UMA ILHA ESCURA, e essa frase decide DUAS cores. Dentro dele o
   fundo é sempre foto sob véu escuro, mesmo que a seção seja clara — então:

     - o título lê --light, e nunca --fg. Mesma decisão do
       midia/galeria-scroll-horizontal.
     - a categoria lê --accent PURO, e nunca --accent-ink. O --accent-ink é
       novo no core e existe porque em escopo claro o accent puro não fecha
       contraste como TEXTO (medido: o sage da Liberta sobre o creme dá
       1.36:1). Aqui não vale, porque a categoria não está sobre o creme: está
       sobre o scrim escuro dentro da foto. Medido nesta peça, o accent puro
       dá de 5.1:1 a 10.4:1 sobre a região real; o --accent-ink na .sec-paper
       cairia para cerca de 3.5:1, porque a mistura escurece a tinta contra um
       fundo que já é escuro. QUEM FIZER UMA VARREDURA trocando --accent por
       --accent-ink em texto vai piorar este componente sem que nada acuse.

   As duas escolhas são a mesma escolha, e é o que faz o arquivo servir aos
   quatro escopos de tom sem uma variação de código.

   O QUE NÃO ESTÁ AQUI, de propósito:
     - anel de foco. O card é o próprio <a>, então o :focus-visible do core
       desenha o anel em volta da caixa do CARD, que é a área do clique. Um
       `outline` local seria pior que o de graça, e um `outline:none` mataria
       o único sinal de onde o teclado está (ver MANUAL §6).
     - .glass. São quatro cards de foto cheia: o vidro não tem o que
       translucidar e ainda pressupõe fundo escuro.
     - .sec-head. A primitiva do core é cabeçalho CENTRADO; aqui olho, título
       e apoio são alinhados à esquerda, na coluna, como numa dobra de texto.
   ========================================================================== */

.gcs{
  /* Vão entre as células. Estreito de propósito: o que se lê é a FILEIRA, e
     vão largo transforma quatro cards em quatro peças soltas. */
  --gcs-gap:clamp(12px,1.4vw,18px);
  /* Proporção da célula, e é UMA para todas — trocar aqui troca as quatro
     juntas, que é justamente a razão de o número morar no ancestral e não no
     card. 3:4 é o retrato da casa e a proporção exata dos placeholders
     900x1200: no preview o object-fit não corta nada, e o que se vê é o que
     o consumidor vai entregar. */
  --gcs-ratio:3/4;
  /* --dur do core é .9s: certo para entrada de dobra, arrastado para a foto
     responder ao ponteiro. Duração de interação é local. */
  --gcs-dur:.55s;
  /* Quanto o véu desfocado sobe ACIMA da legenda, em fração da altura DELA.
     Não é fração do card, e a diferença é o que impede uma falha: a legenda é
     ancorada embaixo e cresce para dentro da foto, então um véu medido em % do
     card é ultrapassado por um título de três linhas em tela estreita — medido
     em 320px, legenda de 122px contra véu de 93px, e a primeira linha do título
     caía sobre a parte nítida da foto. Medido em fração da legenda, o véu
     acompanha o texto que ele existe para servir e não pode ficar atrás. */
  --gcs-veil-fade:85%;
}

.gcs-grid{
  display:grid;
  /* minmax(0,1fr) e não 1fr: o mínimo automático de uma célula de grade é o
     CONTEÚDO dela, e com 1fr um título longo empurraria a coluna e
     desigualaria a fileira — que é exatamente o defeito que esta peça existe
     para não ter. */
  grid-template-columns:repeat(4,minmax(0,1fr));
  gap:var(--gcs-gap);
  margin-top:clamp(40px,6vh,68px);
}

.gcs-item{min-width:0}

/* ---------------------------------------------------------------- o card */
/* É o <a>. Não é um <article> com link esticado por ::after: sendo o próprio
   link a caixa do card, o clique, o foco e o anel de foco falam da mesma
   área, sem pseudo-elemento nenhum no meio. */
.gcs-card{
  display:block;
  position:relative;
  overflow:hidden;
  /* Contexto de empilhamento próprio, para o desfoque do véu não vazar para
     as células vizinhas. Herdado do conteudo/pilares-texto-na-foto. */
  isolation:isolate;
  aspect-ratio:var(--gcs-ratio);
  border-radius:var(--radius-media);
  /* Enquanto a foto não pinta, o card é uma caixa da cor da seção e não um
     retângulo branco. */
  background:var(--bg);
  border:1px solid var(--rule);
  color:inherit;
  text-decoration:none;
}

/* A borda NÃO muda no hover, e isso é escolha: o hover já acende a foto e o
   filete de marca embaixo. Uma terceira coisa piscando ao mesmo tempo lê
   como falha de renderização, não como resposta. */

/* ---------------------------------------------------------------- a foto */
.gcs-img{
  position:absolute;inset:0;
  width:100%;height:100%;
  object-fit:cover;
  /* O enquadramento é do CARD, não do arquivo: quem publica ajusta por
     --gcs-pos no style em vez de recortar a foto de novo. */
  object-position:var(--gcs-pos,50% 50%);
  /* Dessaturação parcial, nunca total: em repouso a fileira fica coesa e o
     card sob o ponteiro ganha cor sem virar outra imagem. */
  filter:grayscale(.42);
  transition:filter var(--gcs-dur) var(--ease);
}

/* :focus-visible junto do :hover — quem chega pelo teclado vê o mesmo card
   acender que quem chega pelo mouse. */
/* 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. */
.gcs-card:hover .gcs-img,
.gcs-card:focus-visible .gcs-img{filter:grayscale(0)}

/* ---------------------------------------------------------- véu da legenda */
/* Duas camadas, e uma não substitui a outra (contrato §6, e a receita é a do
   conteudo/pilares-texto-na-foto):
     scrim  degradê que garante contraste em qualquer foto;
     veil   desfoque que separa o texto do DETALHE da foto.
   Só o degradê deixa o texto sobre textura; só o desfoque deixa claro demais
   em foto clara. */
/* O degradê chega a zero em 92% da altura, e não em 72% como na primeira
   versão. A diferença só aparece em tela estreita: com título de três linhas
   em 320px a legenda ocupa 72% do card, e ali o scrim antigo já era zero — a
   primeira linha ficava por conta do desfoque, que sozinho não garante
   contraste em foto clara (MANUAL §6). O custo é um véu quase imperceptível
   no alto de toda foto: .04 em 85% da altura. */
.gcs-scrim{
  position:absolute;inset:0;z-index:1;pointer-events:none;
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.94) 0%,
    rgba(var(--dark-rgb),.74) 22%,
    rgba(var(--dark-rgb),.34) 48%,
    rgba(var(--dark-rgb),.14) 68%,
    transparent 92%);
}

/* ------------------------------------------------------------- a legenda */
.gcs-cap{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  padding:18px;
}

/* O véu desfocado. É um ::before DA LEGENDA, e não um elemento irmão com
   altura própria, por duas razões:

   1. ele mede a legenda. `top` negativo em porcentagem resolve contra a
      altura do .gcs-cap, então o véu é sempre 1.85x o texto que ele serve —
      cresce com um título de três linhas e encolhe com um de uma;
   2. sendo pseudo-elemento IRMÃO do texto na ordem de pintura, e não
      ancestral dele, a máscara não toca o título. Máscara aplica-se aos
      descendentes: o texto dentro do elemento mascarado apareceria esmaecido
      no topo, e esse é o erro que a separação evita.

   backdrop-filter SEMPRE com máscara em degradê (contrato §6). Caixa reta
   produz borda dura contra a parte nítida da foto e lê "chapado" — é a
   diferença entre um véu e uma tarja. Se alguém apagar a máscara para
   "simplificar", é isso que volta.

   z-index:-1 dentro do .gcs-cap, que já é contexto de empilhamento próprio
   (position + z-index:2): o véu fica atrás do texto e ainda assim acima do
   scrim e da foto. */
.gcs-cap::before{
  content:"";
  position:absolute;
  top:calc(-1 * var(--gcs-veil-fade));
  left:0;right:0;bottom:0;
  z-index:-1;pointer-events:none;
  backdrop-filter:blur(8px) saturate(1.12);
  -webkit-backdrop-filter:blur(8px) saturate(1.12);
  -webkit-mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 46%,transparent 100%);
  mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 46%,transparent 100%);
}

.gcs-cat{
  display:block;
  font-family:var(--sans);font-weight:400;
  font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;
  /* --accent PURO, não --accent-ink: ver o cabeçalho deste arquivo. A tinta
     de realce do escopo existe para texto sobre o FUNDO DA SEÇÃO; esta linha
     está sobre o scrim escuro dentro da foto. */
  color:var(--accent);
}

/* Categoria como LINHA, e não como a .pill do core: a célula desktop mede
   ~260px e sobram ~216px de texto útil. "Consolidação" numa pílula de 11px
   com tracking de .16em mais 24px de padding encosta na borda, e pílula que
   quebra em duas linhas lê como defeito. A .pill continua certa em card
   largo — é o que o midia/galeria-scroll-horizontal faz, com card de 460px. */

.gcs-title{
  font-family:var(--display);font-weight:700;
  font-size:17px;line-height:1.16;letter-spacing:-.02em;
  color:var(--light);
  margin-top:7px;
}

/* O text-wrap:balance que o core dá a todo h1..h4 FICA. Numa caixa estreita
   ele iguala as linhas do título, e quatro legendas de linhas iguais é o que
   faz a fileira parecer composta em vez de justificada à sorte. Aqui o
   text-wrap:pretty do prova/manchetes-imprensa seria o contrário do que se
   quer: lá o alvo é o bloco cheio de jornal, aqui é a mancha simétrica. */

/* ------------------------------------------------- o filete que acende */
/* Afordância de link, no lugar do distintivo redondo do
   midia/galeria-scroll-horizontal: disco de 38px come um oitavo da largura de
   uma célula de 260px, e o filete não come nada. Mora num ::after do CARD, e
   não num filho, porque é decoração e não conteúdo. */
.gcs-card::after{
  content:"";
  position:absolute;left:0;right:0;bottom:0;z-index:3;
  height:2px;
  background:var(--accent);
  transform:scaleX(0);transform-origin:left center;
  transition:transform .45s var(--ease);
  /* Sem isto o filete cobre os 2px de baixo do card e come o clique ali. */
  pointer-events:none;
}
.gcs-card:hover::after,
.gcs-card:focus-visible::after{transform:scaleX(1)}

/* Toque não tem hover, e sem esta regra o celular ficaria com quatro fotos
   dessaturadas para sempre e sem nenhum sinal de que os cards levam a algum
   lugar. Na ausência de ponteiro os dois estados de hover passam a ser o
   estado de repouso: foto em cor cheia e o filete de marca aceso. */
@media (hover:none){
  .gcs-img{filter:grayscale(0)}
  .gcs-card::after{transform:scaleX(1)}
}

/* Colado nas regras que animam (contrato §6.6). Sai o DESLOCAMENTO — o
   crescimento do filete, que agora aparece inteiro de uma vez. Fica a resposta: a cor volta à foto e o filete acende no card sob o
   ponteiro. Apagar as duas transformaria o card numa foto morta, e movimento
   reduzido desliga o que anda sozinho, não o que a pessoa pediu. */
@media (prefers-reduced-motion:reduce){
  .gcs-img,
  .gcs-card::after{transition:none}
}

/* ------------------------------------------------- variação: ver mais */
/* A grade com mais cards do que a primeira fileira mostra. O script
   esconde os data-gcs-extra e mostra este invólucro, que nasce [hidden].
   O botão é o .btn-ghost do core, sem retoque.

   O NÚMERO DE CARDS ESCONDIDOS SEGUE A REGRA DA GRADE: múltiplo de quatro
   (de dois no celular, que é múltiplo de quatro também). Três escondidos
   abririam uma fileira com uma célula vazia — o defeito que a peça existe
   para não ter. */
.gcs-acoes{
  display:flex;
  justify-content:center;
  margin-top:clamp(28px,4vh,44px);
}
.gcs-acoes[hidden]{display:none}          /* contrato §6.5 */

/* ---------------------------------------------------------------- mobile */
/* DUAS colunas, nunca uma, e é a decisão de desenho desta peça. A grade
   existe porque mostra o conjunto de uma vez; empilhar em coluna única a
   transformaria numa lista de ~1900px de rolagem — o defeito do trilho sem
   nenhuma das compensações dele. Medido em 390px: o 2x2 mede 951px de altura
   com a dobra inteira, e as quatro células continuam caindo numa tela.
   Não existe passo de TRÊS colunas: quatro itens em três colunas deixam uma
   célula vazia, que é o defeito que esta peça existe para não ter.

   O CORPO DO TÍTULO É CLAMP AQUI DENTRO, e fixo lá em cima, e a ordem
   importa. Medido: na faixa de quatro colunas a célula varia pouco (206px em
   981 de tela, 262px em 1240) e um número fixo acerta a faixa inteira. Na de
   duas colunas ela varia de 126 a 426px — mais de três vezes — e aí o corpo
   tem de acompanhar. O que NÃO funciona é um clamp por vw atravessando a
   troca de contagem de coluna: em 981px a célula tem 206px e o clamp
   devolveria o corpo maior; em 980px ela salta para 426px e o clamp
   devolveria o menor. O tamanho andaria ao contrário da caixa, e isso foi
   medido antes de virar isto. */
@media (max-width:980px){
  .gcs-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
  .gcs-title{font-size:clamp(15.5px,2.9vw,22px)}
  .gcs-cap{padding:clamp(13px,2.4vw,24px)}
}

script.js · 67 linhas · 3,0 KB

/* ==========================================================================
   GRADE DE CASES COM FOTO — gcs-
   SÓ SERVE À VARIAÇÃO "ver mais". A grade-padrão é CSS puro e continua
   sendo: sem [data-gcs-mais] na raiz, este script sai no guard e não toca
   em nada.

   A variação: a grade traz TODOS os cards no HTML, e os que passam da
   primeira fileira levam data-gcs-extra. O script os esconde, mostra o
   botão (que nasce dentro de um invólucro [hidden]) e alterna os dois
   estados. Sem JavaScript, nada se esconde: a pessoa vê a grade inteira e
   nenhum botão morto — que é o piso certo para uma lista de links.

   A COPY DO BOTÃO MORA NO MARKUP (data-gcs-rot-mais / data-gcs-rot-menos),
   com {n} no lugar da contagem. É a lição do LIB.pausavel do core: rótulo
   cravado em JavaScript é rótulo que nenhum redator acha.

   Referência: o "Ver mais (+3)" do portfólio do Dudes Becker.
   ========================================================================== */
(function () {
  var raizes = document.querySelectorAll('[data-gcs]');
  if (!raizes.length) return;                    // guard: saída silenciosa

  var seq = 0;

  [].forEach.call(raizes, function (raiz) {
    var botao = raiz.querySelector('[data-gcs-mais]');
    var grade = raiz.querySelector('.gcs-grid');
    if (!botao || !grade) return;
    var extras = grade.querySelectorAll('[data-gcs-extra]');
    if (!extras.length) return;

    /* aria-controls precisa de um id, e duas grades na mesma página não
       podem dividir o mesmo. Quem escreveu um id no markup fica com ele. */
    if (!grade.id) grade.id = 'gcs-grade-' + (++seq);
    botao.setAttribute('aria-controls', grade.id);

    var aberto = false;

    function aplicar() {
      [].forEach.call(extras, function (li) { li.hidden = !aberto; });
      var rot = botao.getAttribute(aberto ? 'data-gcs-rot-menos' : 'data-gcs-rot-mais') ||
                (aberto ? 'Ver menos' : 'Ver mais ({n})');
      botao.textContent = rot.replace('{n}', '+' + extras.length);
      botao.setAttribute('aria-expanded', aberto ? 'true' : 'false');
    }

    botao.addEventListener('click', function () {
      aberto = !aberto;
      aplicar();
      /* Abrir leva o foco ao PRIMEIRO card novo: quem chegou de teclado
         continua a leitura de onde ela cresceu, em vez de ficar no botão
         com quatro cards novos ATRÁS dele na ordem de tabulação. Fechar
         deixa o foco no botão — ele é o que sobrou na tela. Foco posto por
         script depois de um clique de mouse não acende o anel no Chrome:
         quem usa mouse não vê nada mudar além dos cards. */
      if (aberto) {
        var primeiro = extras[0].querySelector('a');
        if (primeiro) primeiro.focus();
      }
    });

    aplicar();
    /* O invólucro nasce [hidden] no markup: sem script, o botão seria um
       controle que não comanda nada. */
    if (botao.parentNode && botao.parentNode.hasAttribute('hidden')) botao.parentNode.hidden = false;
  });
})();

Quatro retratos em fileira, dentro da coluna. Cada um é um link de verdade: a foto ocupa o card inteiro em 3:4, e no rodapé da própria imagem ficam uma categoria em cor de marca e o título, sobre um gradiente mais um véu desfocado com máscara. Sob o ponteiro — ou sob o foco do teclado — a foto ganha cor, dá um passo de zoom e um filete de marca de 2px cresce da esquerda para a direita no pé do card. Em telas estreitas a grade vira 2×2, nunca coluna única. Sem uma linha de JavaScript.

O problema editorial que ela resolve não é "mostrar fotos": é declarar um conjunto fechado. A biblioteca já tinha cases com foto em faixa horizontal, e faixa horizontal esconde de propósito o que está fora da tela — mostra dois ou três e promete o resto. Isso é o certo para um portfólio, que é uma coleção aberta. Para uma assessoria, quatro frentes de trabalho não são coleção: são o escopo. A grade diz as quatro de uma vez, e a frase da dobra muda de "explore" para "é isto".


Por que uma grade estática, se existe o trilho

A pergunta é justa, e a resposta não é "porque grade é mais simples". O irmão midia/galeria-scroll-horizontal tem o mesmo modelo de conteúdo — foto, categoria e nome dentro da imagem, card como <a> — e é uma peça melhor que esta em tudo o que envolve quantidade. São quatro diferenças reais, medidas, e só a primeira é editorial:

1. O trilho esconde; a grade mostra. A ficha do trilho declara isso como virtude ("a faixa mostra 2 ou 3 e promete o resto sem ocupar altura") e como limite ("quando os itens precisam ser comparados, scroll horizontal esconde o que está fora da tela"). Um conjunto que se lê como lista fechada de quatro não pode ter um quarto item fora da tela: a pessoa não sabe que ele existe, e o argumento "estas são as nossas frentes" fica pela metade. Em 1440 a grade entrega as quatro células de 260×347 na mesma tela; em 390 entrega as quatro em 951px de dobra, o que ainda cabe numa rolagem curta.

2. A grade não pede gesto, e por isso não precisa de compensação. O trilho custou 141 linhas de script para ser alcançável sem roda horizontal: setas ←/→, Home/End, dois botões de disco, uma dica que cutuca, e um bloco de navegação que se esconde quando não há o que rolar. Aqui são zero linhas. Os quatro cards são <a> na ordem do documento; Tab passa neles, Enter abre, e o anel de foco é o :focus-visible do core — medido, 2px solid rgb(198,206,170) com recuo de 3px em volta do card inteiro.

3. Nada fica fora do viewport inicial, e isso muda a entrada. O trilho tem um gotcha próprio: data-rv usa IntersectionObserver, que considera o recorte do contêiner que rola, então o card à direita da faixa só revela quando a pessoa o traz. Aqui os quatro estão na dobra desde o começo e escalonam juntos com data-d="1|2|3", da esquerda para a direita — que é a ordem em que se lê uma fileira.

4. A grade fica na coluna; o trilho sangra. O trilho é irmão do .wrap e ocupa a página inteira de propósito, o que quebra o ritmo vertical — é metade do valor dele. Numa página que já tem uma faixa que sangra (e a biblioteca tem várias: midia/faixa-horizontal-pinada, midia/esteira-dupla-imagens, midia/carrossel-perfis), a segunda não paga. Esta grade convive com qualquer uma delas.

O que NÃO é diferença: número de itens. Com quatro cards o trilho funciona — é exatamente o mínimo que a ficha dele declara. Quem escolher entre os dois com quatro itens na mão está escolhendo entre conjunto fechado e coleção aberta, não entre "cabe" e "não cabe".


Quando usar

Quando não usar

Contra os vizinhos

Precisa de Use
Conjunto fechado de 4 (ou 8) itens com foto, cada um com página própria este
Coleção aberta, 4 a 8 e crescendo, que convida a explorar midia/galeria-scroll-horizontal
Item com foto e 35 a 55 palavras de leitura, sem página própria overlay/card-expansivel-fullscreen
Três conceitos com foto e uma frase, sem destino conteudo/pilares-texto-na-foto
Quatro blocos de credencial, sem foto conteudo/grid-entidades
Fotos que valem como coleção, sem nome e sem destino midia/galeria-pasta
Clima e presença, com foto como textura e nada clicável midia/colunas-cascata
Grade de cards clicáveis com texto, sem foto prova/manchetes-imprensa

Padrão editorial

Slot Regra
Olho (.eyebrow) 2 a 3 palavras. Diz o recorte e admite que é um recorte: "Situações recorrentes", "Frentes de trabalho". Não escreva "Nossos cases" — a fileira fechada já promete conjunto completo, e "nossos" transforma situação em troféu.
Título (h2.sec-title) 3 a 6 palavras, uma delas em <em>. Nomeia o conjunto: "Onde o trabalho começa".
Apoio (.sec-lead) 1 a 2 frases, opcional. Diz o critério de entrada — por que estas quatro e não outras — e, se os cards levam a página própria, diz isso também. É o que impede alguém de clicar num card achando que ele abre um painel.
Categoria (.gcs-cat) 6 a 14 caracteres, uma palavra, do mesmo conjunto fechado nos quatro cards: "Sucessão", "Consolidação", "Liquidez", "Internacional". Acima de 14 caracteres a linha encosta na borda da célula de 260px. Se cada card inventa a própria categoria, a linha deixa de classificar e passa a decorar.
Título do card (.gcs-title) 4 a 6 palavras, teto de 40 caracteres. Nomeia a situação e o trabalho, nunca o resultado — ver Compliance. Passando de 40 caracteres o título vai a três linhas em 390px e come metade do card.
alt de cada foto 30 a 70 caracteres. Descreve a foto, não o case: o título do case já está na legenda dentro da imagem e seria anunciado duas vezes.
href de cada card Estrutural, não opcional. O card inteiro é um link. Sem página de destino, o componente é o errado.

Quantidade: exatamente 4. É o único número que fecha a fileira.

Os quatro títulos no mesmo tipo gramatical e de comprimento parecido. Aqui isso é regra editorial e não gosto, e o motivo é uma propriedade da forma: a legenda é ancorada no rodapé e cresce para dentro da foto, então um título de três linhas ao lado de um de uma linha não desalinha a grade — as quatro caixas continuam idênticas ao pixel. O que desalinha é a linha de base do texto, e isso aparece como descuido. Medido: com os quatro títulos entre 28 e 36 caracteres e nenhuma palavra acima de 9 letras, as quatro legendas fecham na mesma altura em toda largura testada — 99,2px em 1440, 96,3px em 980, 104,5px em 700, 85,7px em 390. Foi tentativa e erro medido: o quarto card já disse "Patrimônio em dois países" (25 caracteres, uma linha só, legenda de 79px e um degrau visível na fileira) e "Patrimônio distribuído em dois países" (37 caracteres, que fecha em 1440 e vira três linhas em 390, porque distribuído tem 11 letras e a linha de 169px cabe 17). O que fechou nas duas pontas foi "Contas e imóveis em dois países": 31 caracteres em palavras curtas, que empacotam em duas linhas nos dois orçamentos.

Palavra longa custa mais que caractere. Em 390px a linha do título cabe ~17 caracteres, então uma palavra de 12 ("participação", "internacional") força a quebra antes da conta de caracteres fechar. Ao calibrar no celular, olhe a palavra mais longa, não o total.

Especificação dos assets

São quatro fotos, e nenhuma delas atravessa esta biblioteca (contrato §11). O preview usa ph-5, ph-4, ph-3 e ph-6 de showroom/demo/.

Proporção retrato 3:4, recortada por aspect-ratio + object-fit:cover. Uma proporção para as quatro células, resolvida no CSS: não recorte os arquivos no mesmo tamanho — ajuste o enquadramento por --gcs-pos
Dimensão mínima 900 × 1200. A célula maior acontece na faixa de duas colunas, em 980px de tela: 427px de largura. 900px cobrem isso com folga de 2× em tela retina. Acima de 1400 × 1867 não há ganho
Peso até 220 KB cada. São quatro na mesma dobra, e as quatro carregam com loading="lazy"
Onde o texto cobre os 30% de baixo em desktop, e até 55% numa célula de 390px com título de três linhas. Nada de importante ali: nem rosto, nem horizonte, nem o detalhe que justifica a foto. O terço inferior tem de aceitar texto claro por cima
Tom contraste médio a escuro. A foto entra em grayscale(.42) em repouso e volta ao normal sob o ponteiro — foto já dessaturada não tem para onde ir. Em toque a dessaturação não existe (ver Gotchas), então escolha pensando na versão em cor
Enquadramento assunto no terço central superior. object-position padrão é 50% 50%; cada card declara o próprio no style
O que não pode ter texto dentro da foto (colide com a legenda); logo de instituição real; marca d'água; moldura, sombra ou borda no arquivo — a quina vem de --radius-media; rosto identificável de pessoa real sem autorização, porque nesta dobra a foto não é creditada em lugar nenhum

As quatro fotos precisam ser do mesmo tratamento, e isso dá para medir em vez de discutir. Mesma temperatura de cor, mesma profundidade, mesma quantidade de luz: numa fileira de quatro, lado a lado e do mesmo tamanho, a costura entre dois bancos de imagem diferentes aparece na primeira olhada — é o teste mais duro que uma grade regular impõe às fotos, e é o preço de mostrar tudo de uma vez.

O critério objetivo é a luminância média dos quatro arquivos, e a conta cabe em cinco linhas:

from PIL import Image, ImageStat
for f in ['a.webp','b.webp','c.webp','d.webp']:
    print(f, round(ImageStat.Stat(Image.open(f).convert('L')).mean[0], 1))

O primeiro conjunto de placeholders desta peça media de 60,9 a 108,8 — 48 pontos de amplitude —, e a quarta célula lia como um erro de exportação ao lado das outras três. O conjunto que ficou (ph-5, ph-4, ph-3, ph-6) mede de 52,9 a 68,2: 15 pontos. Vale como número de partida — abaixo de 20 pontos a fileira lê como um ensaio; acima de 40 lê como colcha de retalhos, e nenhum tratamento de CSS conserta isso.


Como usar

<link rel="stylesheet" href="componentes/midia/grade-cases-foto/estilo.css">

Sem script. O core (tokens.css, base.css, motion.css, motion.js) é o que faz a dobra entrar; a grade, o hover e o foco são CSS puro. Sem JavaScript nenhum a página continua legível: o markup é todo estático, e o @media (scripting:none) do core/motion.css devolve a visibilidade dos [data-rv].

Ganchos

Gancho Papel
--gcs-pos enquadramento da foto, no style de cada card. 50% 38% sobe o corte; 50% 50% é o padrão
--gcs-ratio proporção das quatro células de uma vez. Mora no .gcs e não no card justamente para não haver como desigualar a fileira
--gcs-gap vão entre as células
--gcs-dur ritmo da resposta da foto ao ponteiro (.55s)
--gcs-veil-fade quanto o véu desfocado sobe acima da legenda, em fração da altura dela (85%)
escopo de tom .sec-dark, .sec-deep, .sec-paper ou .sec-light na seção. O card não muda

Variações


Gotchas


Compliance

A peça não calcula nada e não captura dado, mas carrega afirmação sobre trabalho feito — e é aí que ela pisa em terreno regulado.

O que trocar antes de publicar Por quê
Os quatro títulos Cada um nomeia uma situação e o trabalho que ela pede: "Empresa familiar na terceira geração", "Carteira dividida em quatro bancos". Nunca resultado: "Rendeu 14% em dois anos", "Dobrou o patrimônio em cinco anos" e qualquer variação de retorno passado ou insinuado não entram, com número ou sem.
Nome de cliente Não aparece em lugar nenhum — nem no título, nem no alt, nem no href. Case de assessoria fala de situação, não de pessoa.
Marca de instituição Nenhum logo de banco, corretora, seguradora ou gestora, nas fotos ou fora delas.
As fotos Não são fotos dos clientes. Se forem fotos de pessoas reais, tem de haver autorização de imagem — nesta dobra a foto não é creditada em lugar nenhum, e não há onde pôr a ressalva.
Os quatro href Se a página de destino descreve o case com número, a ressalva mora lá, onde a pessoa lê o número — não aqui. Se não há página, o componente é o errado.

O conteúdo do markup.html é ilustrativo: quatro situações genéricas de assessoria patrimonial, sem cliente, sem instituição e sem cifra. Ele passa como está em qualquer revisão porque não afirma resultado nenhum — e é esse o padrão a manter, não o texto exato.


Histórico

Projeto Onde Data O quê
autoral — 2026-09-09 Construído do zero na biblioteca. Sem referência única: grade de quatro capas com legenda na foto existe em quase todo site de estúdio, de escritório e de incorporadora.
referência: Dudes Becker https://portfolio.dudesbecker.com/portfolio — "Ver mais (+3)" em cada frente 2026-10-01 variação ver mais: o primeiro script.js da peça, que só age com [data-gcs-mais]; a grade-padrão continua CSS puro

Por que ela foi escrita. A lacuna estava declarada na ficha do vizinho: o midia/galeria-scroll-horizontal diz, no "quando não usar", que com menos de quatro cards "sobra um flex comum que uma grade faria melhor", e que "scroll horizontal esconde o que está fora da tela; comparação exige tudo à vista". As duas frases apontam para um componente que ninguém tinha escrito. Antes desta peça, 4 colunas tinha um único card no bloco da vitrine (conteudo/grid-entidades, sem foto), e nenhuma grade estática de cards de foto clicáveis existia na biblioteca — as quatro peças com foto e link eram todas trilho, pilha ou sobreposição.

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

Tentação Aqui Por quê
<article> com link esticado por ::after o card é o <a> o anel de foco do core passa a cercar o card, e não o texto; e o componente não escreve CSS de foco nenhum
<span> no título, para o <a> ficar "limpo" <h3> dentro do <a> devolve os quatro títulos ao sumário de cabeçalhos, que é como um leitor de tela varre a página
véu com altura fixa em % do card ::before da legenda, 1,85× a altura dela legenda ancorada embaixo cresce para dentro; véu medido no card é ultrapassado por título de três linhas (medido em 320px)
legenda em tarja abaixo da foto dentro da imagem, ancorada embaixo contrato §6, e de quebra as quatro caixas ficam idênticas mesmo com títulos de comprimentos diferentes
pedir foto já recortada em 3:4 aspect-ratio + object-fit + --gcs-pos recorte é problema de CSS, não de quem entrega a foto
.pill do core na categoria linha em caixa alta, cor de marca numa célula de 260px a pílula encosta na borda
distintivo redondo no hover, como no irmão filete de 2px no pé do card 38px de disco comem um oitavo da largura da célula
clamp por vw no corpo do título fixo em 4 colunas, clamp dentro da media query de 2 um clamp por vw atravessando a troca de coluna anda ao contrário da caixa
empilhar em coluna única no celular 2×2 até 320px coluna única daria ~1900px de rolagem: o defeito do trilho sem nenhuma das compensações dele

Divergências conhecidas vs. produção: nenhuma — a peça nasce aqui.

Calibragem no primeiro uso. Espere ajustar duas coisas, e as duas dependem do acervo de fotos do cliente: o --gcs-pos de cada card (o corte que o assunto aguenta) e o comprimento dos quatro títulos, que é o que decide se as legendas fecham na mesma altura.