Communitygithub.com

karero/website-motion

Dos efectos, deliberadamente. El movimiento es donde un sitio empieza a verse hecho a máquina.

¿Qué es website-motion?

Esta skill añade dos efectos de movimiento controlados y accesibles a un sitio web Astro: revelación de encabezados al hacer scroll y animación de contadores numéricos en estadísticas. Su objetivo principal es evitar que estos efectos introduzcan bugs de accesibilidad, garantizando que ningún contenido quede oculto o inalcanzable para usuarios con JavaScript desactivado, lectores de pantalla o que prefieran movimiento reducido. La skill proporciona código CSS y JavaScript listo para usar, junto con un contrato de tres reglas de seguridad que priorizan la visibilidad del contenido en todos los escenarios: sin JS, con movimiento reducido y en impresión. Además, incluye una suite de pruebas (motion.spec.ts) que verifica que el contrato se cumple, y modifica la prueba de accesibilidad (a11y) para que axe evalúe el estado final de la página cuando el movimiento está desactivado, evitando falsos positivos. Está diseñada para sitios con páginas largas que realmente se beneficien de estos efectos, y requiere que el marcado HTML, los selectores CSS y la configuración de pruebas se ajusten al proyecto concreto.

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

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Movimiento en el sitio web

Dos efectos, deliberadamente. El movimiento es donde un sitio empieza a verse hecho a máquina: aplicado a cada tarjeta y párrafo se lee como una plantilla, y es la forma más fácil de perder contenido por un bug de accesibilidad que nadie nota. Esta skill incluye los dos efectos que merecen su lugar y, más importante aún, el contrato que los hace seguros.

Solo añade esto cuando la página sea realmente larga. En un sitio de tres secciones, una revelación es ruido. El contador necesita números que un visitante deba detenerse a leer (conteo de asistentes, años de funcionamiento, proyectos entregados), no decoración.

Requisito previo: website-qa debe estar en verde primero. Esto añade un archivo de prueba a esa suite y cambia cómo se ejecuta la compuerta de accesibilidad.

Los nombres de clase a continuación son ejemplos, no los del andamio

.section-title, .section-subtitle, .hero-stat-number y .hero aparecen cero veces en las plantillas Astro de new-website. Provienen de un sitio real del que se extrajeron. Nada funciona aquí hasta que apuntes cada selector a tu propio marcado: el CSS, los dos scripts y el bloque CONFIG al inicio de templates/motion.spec.ts. La especificación se salta con una razón explícita cuando un selector se deja vacío, por lo que una copia no configurada reporta "no aplicable" en lugar de fallar de una manera que parezca un error del producto.


0. La verificación de una línea que no es movimiento

Antes de añadir nada, mide el héroe:

const h = document.querySelector('.hero');           // tu selector del héroe
({ vh: innerHeight,
   heroHeight: h?.getBoundingClientRect().height ?? null,
   nextSectionVisible: h?.nextElementSibling
     ? innerHeight - h.nextElementSibling.getBoundingClientRect().top : null })

Si min-height: 100vh está vinculando — el contenido del héroe es más corto que el viewport — la siguiente sección comienza exactamente en el pliegue y la página se lee como una pantalla de presentación. Reduce el mínimo hasta que la siguiente sección asome por el borde.

No copies un número de otro sitio. El valor correcto es función de la altura del contenido del héroe. Si el contenido ya supera el viewport, cambiar min-height no hace nada en ese tamaño de pantalla, y la palanca real es recortar un párrafo del héroe. Mide, luego decide.


1. El contrato

Todo lo que sigue existe para mantener una promesa: ningún efecto puede dejar contenido inalcanzable. Tres reglas, en orden de prioridad.

  1. El estado oculto se añade con JS, nunca se escribe en HTML. Un visitante sin JS, o que aterrice a mitad de página, debe ver la página terminada. El script añade la clase de ocultación por sí mismo, y solo a los elementos que están actualmente debajo del pliegue.

  2. prefers-reduced-motion: reduce termina con todo visible y nada en movimiento, incluyendo animaciones que preceden a esta skill. El andamio Astro incluye html { scroll-behavior: smooth } sin anulación, por lo que este es un paso que debes realizar realmente, no una propiedad que heredes:

    /* DESPUÉS de las reglas que anula — ver §6, esta es toda la trampa */
    @media (prefers-reduced-motion: reduce) {
      html { scroll-behavior: auto; }
      /* más cualquier animación ambiental que ejecute este sitio */
    }
    
  3. La impresión es un tercer estado. Nunca se desplaza, por lo que IntersectionObserver nunca se activa y un encabezado revelado se imprime en blanco.


2. Revelación por scroll — solo encabezados de sección

@media (prefers-reduced-motion: no-preference) {
  /* Sin transición en el estado oculto. `reveal` se añade después del primer
     pintado, por lo que una transición aquí haría que un encabezado ya dibujado
     se desvaneciera DURANTE medio segundo antes de poder aparecer — visible
     para cualquiera que se desplace justo después de la carga.
     Con la transición en el estado visible, la ocultación se produce
     instantáneamente y solo la revelación se 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;
  }
  /* Misma especificidad que la regla anterior y POSTERIOR en la hoja, para que
     la abreviatura no pueda restablecer el retardo a cero. */
  .reveal-delay.is-visible { transition-delay: 0.09s; }
}
/* La impresión nunca se desplaza, por lo que el observador nunca se activa. */
@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) {
    // Cualquier cosa ya visible se deja intacta — ocultarla ahora haría que
    // parpadease y volviese a aparecer durante la carga.
    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);
  });
})();

Solo encabezados. El subtítulo va un paso detrás para que el par se lea como un solo movimiento — eso es lo que "escalonado" debería significar aquí. Aplicar esto a tarjetas, elementos de lista y párrafos es el sello distintivo de un sitio generado por IA.


3. Contador de estadísticas del héroe

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

  function countUp(el) {
    // Escribir textContent aplana el marcado hijo permanentemente — un
    // <span>+</span> con estilo dentro del número no sobreviviría a la animación.
    if (el.children.length) return;

    var finalText = el.textContent.trim();
    // Dígitos, un separador de grupo opcional ("," en en, "." en de), un símbolo
    // final opcional. Todo lo demás se deja exactamente como se escribió: estas son
    // afirmaciones publicadas, y "4.9" nunca debe animarse hasta 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] || '';

    // Refleja la agrupación ESCRITA en lugar de una configuración regional en
    // tiempo de ejecución. El ancho congelado a continuación se mide a partir de
    // la cadena escrita, por lo que renderizar "1,200" donde la página dice "1.200"
    // — o agrupar un número que la página dejó sin agrupar — es un ancho diferente,
    // que es exactamente el desplazamiento de fila que la congelación existe para
    // prevenir.
    function render(v) {
      var digits = String(v);
      return (groupSep ? digits.replace(/\B(?=(\d{3})+(?!\d))/g, groupSep) : digits) + suffix;
    }

    // Congela la caja primero. "0+" es mucho más estrecho que "1,200+", y la
    // estadística suele tener el tamaño de su número en lugar de su etiqueta, por
    // lo que un contador sin congelar desplaza toda la fila lateralmente en cada
    // fotograma.
    // Recuerda cualquier min-width en línea ya presente en el elemento — borrarlo
    // al finalizar desharía silenciosamente un cambio de estilo posterior que otro
    // haya hecho.
    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;
    }
    // Una impresión disparada a mitad del contador capturaría una cifra
    // intermedia — un número que el sitio no afirma. La regla 3 del contrato
    // cubre la revelación; esta es la mitad del contador.
    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); });
})();

Los valores finales permanecen en el HTML, por lo que los visitantes sin JS y los rastreadores ven los números reales y la animación solo los reemplaza temporalmente.

min-width solo funciona en una caja de bloque. Si el número de la estadística se calcula como display: inline, la congelación es una no operación silenciosa y la fila aún salta. Comprueba el valor calculado, o establece display: inline-block.

Ejecuta ambos scripts después de que el DOM se haya analizado — defer, un módulo, o al final de <body>. En un script <head> síncrono, la consulta no devuelve nada y ambos efectos están silenciosamente muertos.

4. La compuerta de accesibilidad debe cambiar, o se debilita silenciosamente

Esta es la parte que es fácil pasar por alto, y es la razón por la que esta skill existe en lugar de un fragmento en website-design-system.

Una revelación deja los encabezados con opacity: 0 mientras se ejecuta axe, y axe omite los elementos que considera invisibles. Su contraste de color deja de comprobarse silenciosamente. La suite sigue en verde y la compuerta es más débil que el día anterior.

Fuerza el movimiento reducido en a11y.spec.ts para que axe vea siempre la página terminada. El fragmento a continuación asume la forma for (const path of PAGES) de ese archivo — ajústalo al bucle o fixture que use realmente tu suite. La llamada emulateMedia antes de goto, y el canario debajo de ella, son las partes que soportan la carga:

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

test.use({ reducedMotion: 'reduce' }) puede no ser un sustituto. Medido una vez, en Playwright 1.60, contra la configuración de otro repositorio — no la de este kit de herramientas, cuyo archivo de bloqueo actualmente resuelve 1.64.1. Allí, la forma test.use se aceptó sin quejas mientras la matchMedia de la página aún reportaba no-preference; tanto page.emulateMedia como browser.newContext funcionaron en la misma ejecución. Eso contradice cómo está documentada la opción, y el mecanismo nunca se estableció, por lo que trátalo como una medición más que como una regla sobre Playwright. emulateMedia se usa aquí porque es la forma que realmente se observó que funciona.

Sea cual sea la forma que uses, demuestra que llegó al navegador, porque el fallo es silencioso — y una llamada por prueba está a una línea olvidada de una página que mantiene silenciosamente la compuerta debilitada:

// Canario. Sin él, una compuerta que no hace nada se ve exactamente como una compuerta.
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches),
  'el movimiento reducido nunca llegó al navegador — la compuerta de accesibilidad no es determinista')
  .toBe(true);

5. motion.spec.ts — la prueba que hace real el contrato

Copia el templates/motion.spec.ts de esta skill en el directorio tests/ del sitio. Vive aquí en lugar de en la superposición Astro de new-website a propósito: esa superposición se copia en cada andamio, y esta especificación afirma un efecto que un sitio nuevo no tiene — fallaría el primer día de cada nuevo proyecto.

Cada fila a continuación existe debido a una forma específica en que el contrato se rompe. La columna derecha es para qué sirve la aserción, no una afirmación sobre la frecuencia con la que se ha disparado:

aserciónel error que detecta
Ningún encabezado es invisible después de un scroll completotodo el modo de fallo que esta skill protege
Nada por encima del pliegue está oculto al cargarun encabezado que parpadea visiblemente y se desvanece
Los objetivos de revelación son visibles con media: printla anulación de impresión eliminada o mal ordenada
Con movimiento reducido, nada está oculto, scroll-behavior es auto, nada se está animando, y la regla .reveal se aplica forzosamente para demostrar que el CSS en sí mismo funcionauna animación ambiental que ignora la preferencia; desplazamiento suave dejado activo; y una anulación colocada antes de la regla que anula — misma especificidad, por lo que la original gana y la corrección es una no operación silenciosa
Los números de las estadísticas se establecen en sus valores publicadosalguien editando las cifras en HTML

El paso de clase forzada en la fila 4 no es solo por precaución. Con movimiento reducido, el JS de revelación se anula, por lo que la clase de ocultación nunca se añade y la hoja de estilo nunca se ejercita — sin forzarla, esa fila pasaría con la anulación CSS rota.

Una prueba de "nada se anima" debe cubrir también otras animaciones de la página — un brillo ambiental del héroe, scroll-behavior: smooth. Afirmar solo tu propio efecto hace que el nombre de la prueba sea una mentira.

La aserción del contador es una instantánea de contenido, no una prueba de animación. El fotograma final restaura la cadena escrita, por lo que pasa con la animación muerta. Dilo en el comentario en lugar de implicar una cobertura que no tiene; muestrear una animación rAF de 1.4 s desde el lado de la prueba depende del tiempo y no vale una compuerta inestable.


6. Trampas que cuestan tiempo real de depuración

  • Una anulación de movimiento reducido debe ir DESPUÉS de la regla que anula. Misma especificidad significa que la última gana. Colocada junto a la declaración original se lee de forma más natural y no hace nada.
  • Una tira tipo marquesina no puede pasar el ratón ni ser clickeada por Playwright. Espera a que el elemento deje de moverse y un elemento en bucle nunca lo hace. Conduce el ratón por coordenadas (page.mouse.move) en su lugar.
  • Muestrear una animación en bucle da resultados inestables en el reinicio. Un par antes/después mide una ventana contra el período del bucle — una muestra de 1 s de un bucle de 90 s cae en el reinicio, donde la posición salta hacia adelante, aproximadamente una vez cada noventa ejecuciones. Muestrea varios intervalos y toma la mayoría; solo uno de ellos puede contener el reinicio.
  • Añadir un encabezado cierra el estado oculto de su contenido — si añades un .section-title alrededor de contenido existente, el contenido hereda la opacidad cero. Coloca el contenido dentro del encabezado o usa un wrapper.
  • requestAnimationFrame no se ejecuta en pestañas en segundo plano. El contador se pausa y se reanuda. Si el usuario cambia de pestaña durante el contador, el valor puede congelarse a mitad de camino hasta que la pestaña vuelva a primer plano.

Individual skills in this repo

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

Skills relacionados