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-numbere.heroaparecem zero vezes nos modelos Astro donew-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 blocoCONFIGno topo detemplates/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.
-
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.
-
prefers-reduced-motion: reducetermina com tudo visível e nada se movendo — incluindo animações que antecedem esta skill. O modelo Astro enviahtml { 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 */ } -
Impressão é um terceiro estado. Ela nunca rola, então
IntersectionObservernunca 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 formatest.usefoi aceita sem reclamação enquanto omatchMediada página ainda relatavano-preference;page.emulateMediaebrowser.newContextfuncionaram 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ção | o bug que ela captura |
|---|---|
| Nenhum título está invisível após uma rolagem completa | todo o modo de falha que esta skill protege contra |
| Nada acima da dobra está oculto no carregamento | um título que visivelmente pisca e desaparece e depois reaparece |
Alvos de revelação estão visíveis sob media: print | a 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 produz | uma 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 publicados | algué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]