Communitygithub.com

karero/website-motion

Dois efeitos, intencionalmente. Movimento é onde um site começa a parecer gerado por máquina.

O que é website-motion?

Esta skill adiciona dois efeitos específicos de animação a um site: revelação de seção (scroll reveal) e contagem numérica (count-up), com um contrato rígido de acessibilidade que garante que nenhum conteúdo fique inacessível. Ela é projetada para sites longos, onde uma revelação de seção é necessária, e não para sites curtos de três seções. A skill funciona injetando código CSS e JavaScript, mas exige que o desenvolvedor configure corretamente os seletores CSS para o markup do seu próprio site, conforme descrito em seu SKILL.md. O principal diferencial é que ela inclui um teste (`motion.spec.ts`) que verifica se o contrato de acessibilidade está sendo cumprido, especialmente em relação a `prefers-reduced-motion` e ao modo de impressão, além de modificar o gate de a11y para garantir que os elementos ocultos durante a animação não sejam ignorados pelas ferramentas de auditoria (como axe).

Funciona com~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/karero/croftweaver/tree/HEAD/skills/website-motion

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

Movimento do site

Dois efeitos, intencionalmente. Movimento é onde um site começa a parecer gerado por máquina: aplicado a cada cartão e parágrafo, ele se lê como um modelo, e é a maneira mais fácil de perder conteúdo para um bug de acessibilidade que ninguém percebe. Esta skill entrega os dois efeitos que merecem seu lugar e — mais importante — o contrato que os torna seguros.

Só adicione isso quando a página for realmente longa. Em um site de três seções, uma revelação é ruído. A contagem precisa de números que um visitante deve parar e ler (contagens de participantes, anos de atividade, projetos entregues), não decoração.

Pré-requisito: website-qa verde primeiro. Isso adiciona um arquivo de teste a essa suíte e altera como o gate de a11y é executado.

Os nomes de classe abaixo são exemplos, não os do modelo

.section-title, .section-subtitle, .hero-stat-number e .hero aparecem zero vezes nos modelos Astro do new-website. Eles vêm de um site real do qual isso foi extraído. Nada aqui funciona até que você aponte cada seletor para seu próprio markup — o CSS, os dois scripts e o bloco CONFIG no topo de templates/motion.spec.ts. O spec pula com um motivo declarado quando um seletor é deixado vazio, então uma cópia não configurada relata "não aplicável" em vez de falhar de uma forma que pareça um bug de produto.


0. A verificação de uma linha que não é movimento

Antes de adicionar qualquer coisa, meça o hero:

const h = document.querySelector('.hero');           // seletor do seu hero
({ vh: innerHeight,
   heroHeight: h?.getBoundingClientRect().height ?? null,
   nextSectionVisible: h?.nextElementSibling
     ? innerHeight - h.nextElementSibling.getBoundingClientRect().top : null })

Se min-height: 100vh está vinculando — o conteúdo do próprio hero é mais curto que a viewport — a próxima seção começa exatamente na dobra e a página se lê como uma tela de apresentação. Apare o piso até que a próxima seção apareça sobre a borda.

Não copie um número de outro site. O valor correto é uma função da altura do conteúdo do hero. Se o conteúdo já exceder a viewport, alterar min-height não faz nada nesse tamanho de tela, e a verdadeira alavanca é cortar um parágrafo do hero. Meça, então decida.


1. O contrato

Tudo abaixo existe para manter uma promessa: nenhum efeito pode deixar conteúdo inacessível. Três regras, em ordem de prioridade.

  1. O estado oculto é adicionado por JS, nunca escrito em HTML. Um visitante sem JS, ou um que chegue no meio da página, deve ver a página finalizada. O script adiciona a classe de ocultação por si só, e apenas para elementos que estão atualmente abaixo da dobra.

  2. prefers-reduced-motion: reduce termina com tudo visível e nada se movendo — incluindo animações que antecedem esta skill. O modelo Astro envia html { scroll-behavior: smooth } sem nenhuma substituição, então este é um passo que você deve realmente executar, não uma propriedade que você herda:

    /* APÓS as regras que substitui — veja §6, esta é a armadilha */
    @media (prefers-reduced-motion: reduce) {
      html { scroll-behavior: auto; }
      /* mais qualquer animação ambiente que este site execute */
    }
    
  3. Impressão é um terceiro estado. Ela nunca rola, então IntersectionObserver nunca dispara e um título revelado imprime em branco.


2. Revelação de rolagem — apenas títulos de seção

@media (prefers-reduced-motion: no-preference) {
  /* Sem transição no estado oculto. `reveal` é adicionado após a primeira pintura, então uma
     transição aqui faz um título já desenhado desaparecer por meio segundo
     antes de poder aparecer — visível para qualquer um que role logo após o carregamento.
     Com a transição no estado visível, a ocultação é instantânea e apenas a revelação
     anima. */
  .reveal { opacity: 0; transform: translateY(12px); }
  .reveal.is-visible {
    opacity: 1; transform: none;
    transition: opacity 0.55s ease-out, transform 0.55s ease-out;
  }
  /* Mesma especificidade que a regra acima e DEPOIS na folha, então a abreviação
     lá não pode redefinir o atraso para zero. */
  .reveal-delay.is-visible { transition-delay: 0.09s; }
}
/* A impressão nunca rola, então o observador nunca dispara. */
@media print { .reveal { opacity: 1 !important; transform: none !important; } }
(function () {
  if (!('IntersectionObserver' in window)) return;
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  function partnerOf(heading) {
    var next = heading.nextElementSibling;
    return next && next.matches('.section-subtitle') ? next : null;
  }
  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (!entry.isIntersecting) return;
      io.unobserve(entry.target);
      entry.target.classList.add('is-visible');
      var partner = partnerOf(entry.target);
      if (partner) partner.classList.add('is-visible');
    });
  }, { threshold: 0.15 });

  document.querySelectorAll('.section-title').forEach(function (heading) {
    // Qualquer coisa já na tela é deixada em paz — ocultá-la agora a faria
    // piscar e reaparecer no carregamento.
    if (heading.getBoundingClientRect().top < window.innerHeight) return;
    heading.classList.add('reveal');
    var partner = partnerOf(heading);
    if (partner) partner.classList.add('reveal', 'reveal-delay');
    io.observe(heading);
  });
})();

Apenas títulos. O subtítulo acompanha um batimento atrás para que o par se leia como um movimento — isso é o que "escalonado" deve significar aqui. Aplicar isso a cartões, itens de lista e parágrafos é a marca de um site gerado por IA.


3. Contagem de estatísticas do hero

(function () {
  var nums = document.querySelectorAll('.hero-stat-number');   // seu seletor
  if (!nums.length || !('IntersectionObserver' in window)) return;
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  function countUp(el) {
    // Escrever textContent achata a marcação filha permanentemente — um
    // <span>+</span> estilizado dentro do número não sobreviveria à animação.
    if (el.children.length) return;

    var finalText = el.textContent.trim();
    // Dígitos, um separador de grupo opcional ("," em pt, "." em de), um símbolo
    // final opcional. Todo o resto é deixado exatamente como escrito: estas são
    // afirmações publicadas, e "4.9" nunca deve animar para 49.
    var parts = finalText.match(/^(\d{1,3}(?:([.,])\d{3})*|\d+)(\D*)$/);
    if (!parts) return;
    var target = parseInt(parts[1].replace(/\D/g, ''), 10);
    var groupSep = parts[2] || '';
    var suffix = parts[3] || '';

    // Espelhe o agrupamento ESCRITO em vez de uma localidade em tempo de execução. A
    // largura congelada abaixo é medida a partir da string escrita, então renderizar "1,200"
    // onde a página diz "1.200" — ou agrupar um número que a página deixou
    // desagrupado — é uma largura diferente, que é exatamente o empurrão de linha que o
    // congelamento existe para prevenir.
    function render(v) {
      var digits = String(v);
      return (groupSep ? digits.replace(/\B(?=(\d{3})+(?!\d))/g, groupSep) : digits) + suffix;
    }

    // Congele a caixa primeiro. "0+" é muito mais estreito que "1,200+", e a estatística é
    // geralmente dimensionada pelo seu número em vez de seu rótulo, então uma contagem
    // descongelada empurra toda a linha lateralmente a cada quadro.
    // Lembre-se de qualquer min-width inline já no elemento — limpá-la no
    // assentamento desfaria silenciosamente uma alteração de estilo posterior que outra pessoa fez.
    var priorMinWidth = el.style.minWidth;
    el.style.minWidth = el.getBoundingClientRect().width + 'px';
    var done = false;
    function settle() {
      done = true;
      el.textContent = finalText;
      el.style.minWidth = priorMinWidth;
    }
    // Uma impressão disparada no meio da contagem capturaria uma figura intermediária — um número
    // que o site não reivindica. A regra 3 do contrato cobre a revelação; esta é a
    // metade da contagem.
    window.addEventListener('beforeprint', settle);

    var started = performance.now();
    function frame(now) {
      if (done) return;
      var t = Math.min((now - started) / 1400, 1);
      el.textContent = render(Math.round(target * (1 - Math.pow(1 - t, 3))));
      if (t < 1) requestAnimationFrame(frame);
      else settle();
    }
    requestAnimationFrame(frame);
  }

  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (!entry.isIntersecting) return;
      io.unobserve(entry.target);
      countUp(entry.target);
    });
  }, { threshold: 0.6 });
  nums.forEach(function (n) { io.observe(n); });
})();

Os valores finais permanecem no HTML, então visitantes sem JS e rastreadores veem os números reais e a animação apenas os substitui temporariamente.

min-width só funciona em uma caixa de bloco. Se o número da estatística resultar em display: inline, o congelamento é um no-op silencioso e a linha ainda pula. Verifique o valor computado ou defina display: inline-block.

Execute ambos os scripts após o DOM ser analisado — defer, um módulo ou no final de <body>. Em um script <head> síncrono, a consulta não retorna nada e ambos os efeitos estão silenciosamente mortos.

4. O gate de a11y precisa mudar, ou ele fica silenciosamente mais fraco

Esta é a parte que é fácil de perder, e é a razão pela qual esta skill existe em vez de um snippet em website-design-system.

Uma revelação deixa os títulos em opacity: 0 enquanto o axe é executado, e o axe pula elementos que considera invisíveis. O contraste de cor deles silenciosamente deixa de ser verificado. A suíte permanece verde e o gate fica mais fraco do que era no dia anterior.

Force a redução de movimento em a11y.spec.ts para que o axe veja sempre a página finalizada. O snippet abaixo assume a forma for (const path of PAGES) desse arquivo — ajuste-o para qualquer loop ou fixture que sua suíte realmente use. A chamada emulateMedia antes de goto, e o canário abaixo dela, são as partes que suportam a carga:

for (const path of PAGES) {
  test(`a11y: ${path}`, async ({ page }) => {
    await page.emulateMedia({ reducedMotion: 'reduce' });
    await page.goto(path, { waitUntil: 'load' });
    // …inalterado

test.use({ reducedMotion: 'reduce' }) pode não ser um substituto. Medido uma vez, no Playwright 1.60, contra a configuração de um repositório diferente — não a deste kit de ferramentas, cujo lockfile atualmente resolve a 1.64.1. Lá, a forma test.use foi aceita sem reclamação enquanto o matchMedia da página ainda relatava no-preference; page.emulateMedia e browser.newContext funcionaram ambos no mesma execução. Isso contradiz como a opção é documentada, e o mecanismo nunca foi estabelecido, então trate-o como uma medição, não como uma regra sobre o Playwright. emulateMedia é usado aqui porque é a forma que foi realmente observada funcionar.

Qualquer forma que você use, prove que ela alcançou o navegador, porque a falha é silenciosa — e uma chamada por teste está a uma linha esquecida de uma página que silenciosamente mantém o gate enfraquecido:

// Canário. Sem ele, um gate que não faz nada parece exatamente como um gate.
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches),
  'movimento reduzido nunca alcançou o navegador — o gate de a11y não é determinístico')
  .toBe(true);

5. motion.spec.ts — o teste que torna o contrato real

Copie o templates/motion.spec.ts desta skill para o tests/ do site. Ele vive aqui em vez de na sobreposição Astro do new-website de propósito: essa sobreposição é copiada em cada modelo, e este spec afirma um efeito que um site novo não tem — ele falharia no primeiro dia de cada novo projeto.

Cada linha abaixo existe por causa de uma maneira específica pela qual o contrato quebra. A coluna direita é para o que a asserção é, não uma afirmação sobre quantas vezes ela disparou:

asserçãoo bug que ela captura
Nenhum título está invisível após uma rolagem completatodo o modo de falha que esta skill protege contra
Nada acima da dobra está oculto no carregamentoum título que visivelmente pisca e desaparece e depois reaparece
Alvos de revelação estão visíveis sob media: printa substituição de impressão sendo deletada ou mal ordenada
Sob movimento reduzido, nada está oculto, scroll-behavior é auto, nada está animando, e a regra .reveal é aplicada à força para provar que o próprio CSS produzuma animação ambiente ignorando a preferência; rolagem suave deixada ativa; e uma substituição colocada antes da regra que substitui — mesma especificidade, então a original vence e a correção é um no-op silencioso
Números de estatística se estabilizam em seus valores publicadosalguém editando as figuras no HTML

A etapa de classe forçada na linha 4 não é cinto-e-suspensórios. Sob movimento reduzido, o JS da revelação desiste, então a classe de ocultação nunca é adicionada e a folha de estilo nunca é exercitada — sem forçá-la, essa linha passaria com a substituição de CSS quebrada.

Um teste "nada anima" deve cobrir as outras animações da página também — um brilho de hero ambiente, scroll-behavior: smooth. Afirmar apenas seu próprio efeito faz o nome do teste ser uma mentira.

A asserção de contagem é um instantâneo de conteúdo, não um teste de animação. O quadro final restaura a string escrita, então ele passa com a animação morta. Diga isso no comentário em vez de implicar uma cobertura que ele não tem; amostrar uma animação rAF de 1,4s do lado do teste depende de tempo e não vale um gate instável.


6. Pegadinhas que custam tempo real de depuração

  • Uma substituição de movimento reduzido deve vir DEPOIS da regra que substitui. Mesma especificidade significa que a última vence. Colocada junto à declaração original, ela se lê mais naturalmente e não faz nada.
  • Uma faixa estilo marquee não pode ser pairável ou clicável pelo Playwright. Ele espera que o elemento pare de se mover e um elemento em loop nunca para. Dirija o mouse por coordenada (page.mouse.move) em vez disso.
  • Amostrar uma animação em loop falha na quebra. Um par antes/depois mede uma janela contra o período do loop — uma amostra de 1s de um loop de 90s cai no reinício, onde a posição salta para frente, cerca de uma execução em noventa. Amostre vários intervalos e pegue a maioria; apenas um deles pode conter a quebra.
  • **Adicionar um título c

… [truncado para tradução]

Individual skills in this repo

This repo contains 2 individual skills — each has its own dedicated page.

Habilidades Relacionadas