Communitygithub.com

karero/website-motion

Deux effets intentionnels. Le mouvement est ce qui fait qu'un site commence à ressembler à une machine : appliqué à chaque carte et paragraphe, il ressemble à un modèle et c'est le moyen le plus simple de perdre du contenu à cause d'un bug d'accessibilité que personne ne remarque.

Qu'est-ce que website-motion ?

Cette compétence introduit deux effets de mouvement spécifiques et délibérés (défilement révélateur et compteur de statistiques) pour les sites web, en priorisant l'accessibilité et l'intention de conception. Contrairement aux effets génériques, elle garantit que le contenu reste accessible via un contrat en trois parties : le JS ajoute l'état caché, `prefers-reduced-motion: reduce` affiche tout sans mouvement, et l'impression est traitée comme un état distinct. La compétence inclut des tests de contrat complets dans `motion.spec.ts` et modifie la passerelle a11y pour garantir que les outils comme axe vérifient le contenu dans un état visible. Elle est conçue pour fonctionner avec le kit d'outils `karero/new-website` et nécessite que `website-qa` soit vert au préalable. Les sélecteurs CSS et les configurations doivent être adaptés à la structure HTML spécifique du site.

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

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

Mouvement du site Web

Deux effets intentionnels. Le mouvement est ce qui fait qu'un site commence à ressembler à une machine : appliqué à chaque carte et paragraphe, il devient un modèle, et c'est le moyen le plus simple de perdre du contenu à cause d'un bug d'accessibilité que personne ne remarque. Cette compétence propose les deux effets qui méritent leur place et, plus important encore, le contrat qui les rend sûrs.

N'ajoutez ceci que lorsque la page est réellement longue. Sur un site de trois sections, une révélation n'est que du bruit. Le compteur nécessite des nombres que le visiteur doit s'arrêter pour lire (nombres de participants, années d'existence, projets livrés), pas de la décoration.

Prérequis : website-qa doit d'abord être vert. Ceci ajoute un fichier de test à cette suite et modifie la façon dont la passerelle a11y s'exécute.

Les noms de classes ci-dessous sont des exemples, pas ceux de la structure

.section-title, .section-subtitle, .hero-stat-number et .hero apparaissent zéro fois dans les modèles Astro de new-website. Ils proviennent d'un site réel dont ils ont été extraits. Rien ne fonctionne ici tant que vous n'avez pas pointé chaque sélecteur vers votre propre balisage — le CSS, les deux scripts et le bloc CONFIG en haut de templates/motion.spec.ts. Le spec saute avec une raison énoncée lorsqu'un sélecteur est laissé vide, donc une copie non configurée signale « non applicable » au lieu d'échouer d'une manière qui ressemble à un bogue de produit.


0. La vérification en une ligne qui n'est pas du mouvement

Avant d'ajouter quoi que ce soit, mesurez le héros :

const h = document.querySelector('.hero');           // le sélecteur de votre héros
({ vh: innerHeight,
   heroHeight: h?.getBoundingClientRect().height ?? null,
   nextSectionVisible: h?.nextElementSibling
     ? innerHeight - h.nextElementSibling.getBoundingClientRect().top : null })

Si min-height: 100vh lie — le contenu du héros lui-même est plus court que la fenêtre d'affichage — la section suivante commence exactement au pli et la page donne l'impression d'un écran de démarrage. Réduisez la hauteur minimale jusqu'à ce que la section suivante dépasse le bord.

Ne copiez pas un nombre d'un autre site. La bonne valeur est fonction de la hauteur du contenu du héros. Si le contenu dépasse déjà la fenêtre d'affichage, modifier min-height ne fait rien du tout pour cette taille d'écran, et le vrai moyen est de supprimer un paragraphe du héros. Mesurez, puis décidez.


1. Le contrat

Tout ce qui suit existe pour tenir une seule promesse : aucun effet ne peut laisser le contenu inaccessible. Trois règles, par ordre de priorité.

  1. L'état caché est ajouté par JS, jamais écrit en HTML. Un visiteur sans JS, ou arrivant au milieu de la page, doit voir la page terminée. Le script ajoute lui-même la classe de masquage, et uniquement aux éléments actuellement sous le pli.
  2. prefers-reduced-motion: reduce se termine avec tout visible et rien qui bouge — y compris les animations qui précèdent cette compétence. La structure Astro inclut par défaut html { scroll-behavior: smooth } sans aucune substitution, c'est donc une étape que vous devez réellement effectuer, pas une propriété que vous héritez :
/* APRÈS les règles qu'elle remplace — voir §6, c'est tout le piège */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  /* plus toute animation ambiante que ce site exécute */
}
  1. L'impression est un troisième état. Elle ne défile jamais, donc IntersectionObserver ne se déclenche jamais et un titre révélé s'imprime vide.

2. Révélation au défilement — uniquement les titres de section

@media (prefers-reduced-motion: no-preference) {
  /* Pas de transition sur l'état caché. `reveal` est ajouté après le premier affichage, donc une
     transition ici ferait disparaître un titre déjà affiché pendant une demi-seconde avant
     qu'il puisse apparaître — visible pour quiconque défile juste après le chargement.
     Avec la transition sur l'état visible, le masquage est instantané et seule la révélation
     s'anime. */
  .reveal { opacity: 0; transform: translateY(12px); }
  .reveal.is-visible {
    opacity: 1; transform: none;
    transition: opacity 0.55s ease-out, transform 0.55s ease-out;
  }
  /* Même spécificité que la règle ci-dessus et PLUS TARD dans la feuille, donc le raccourci
     là ne peut pas réinitialiser le délai à zéro. */
  .reveal-delay.is-visible { transition-delay: 0.09s; }
}
/* L'impression ne défile jamais, donc l'observateur ne se déclenche jamais. */
@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) {
    // Tout ce qui est déjà à l'écran est laissé tel quel — le masquer maintenant le ferait
    // disparaître et réapparaître au chargement.
    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);
  });
})();

Titres uniquement. Le sous-titre suit un temps derrière pour que la paire donne l'impression d'un seul mouvement — c'est ce que « décalé » devrait signifier ici. Appliquer cela aux cartes, éléments de liste et paragraphes est la marque distinctive d'un site généré par IA.


3. Compteur de statistiques du héros

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

  function countUp(el) {
    // Écrire textContent aplatit définitivement le balisage enfant — un
    // <span>+</span> stylisé à l'intérieur du nombre ne survivrait pas à l'animation.
    if (el.children.length) return;

    var finalText = el.textContent.trim();
    // Chiffres, un séparateur de groupe facultatif ("," en anglais, "." en allemand), un symbole
    // de fin facultatif. Tout le reste est laissé exactement comme écrit : ce sont des
    // affirmations publiées, et "4.9" ne doit jamais s'animer jusqu'à 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] || '';

    // Reflète le GROUPEMENT ÉCRIT plutôt qu'une locale d'exécution. La largeur
    // figée ci-dessous est mesurée à partir de la chaîne écrite, donc afficher "1 200"
    // là où la page indique "1.200" — ou regrouper un nombre que la page laisse
    // sans regroupement — est une largeur différente, ce qui est exactement le
    // décalage de ligne que le blocage existe pour empêcher.
    function render(v) {
      var digits = String(v);
      return (groupSep ? digits.replace(/\B(?=(\d{3})+(?!\d))/g, groupSep) : digits) + suffix;
    }

    // Bloque d'abord la boîte. "0+" est beaucoup plus étroit que "1 200+", et la statistique est
    // généralement dimensionnée par son nombre plutôt que par son étiquette, donc un compteur
    // non bloqué pousse toute la ligne de côté à chaque image.
    // Se souvenir de toute largeur minimale en ligne déjà présente sur l'élément — l'effacer
    // lors de la stabilisation annulerait silencieusement une modification ultérieure apportée
    // par quelqu'un d'autre.
    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;
    }
    // Une impression déclenchée en plein comptage capturerait une figure intermédiaire — un nombre
    // que le site ne revendique pas. La règle 3 du contrat couvre la révélation ; c'est la
    // moitié du compteur pour cela.
    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); });
})();

Les valeurs finales restent dans le HTML, donc les visiteurs sans JS et les robots d'indexation voient les vrais nombres et l'animation ne les remplace que temporairement.

min-width ne fonctionne que sur une boîte de bloc. Si le nombre de la statistique a un display: inline, le blocage est un no-op silencieux et la ligne saute encore. Vérifiez la valeur calculée, ou définissez display: inline-block.

Exécutez les deux scripts une fois le DOM parsé — defer, un module, ou à la fin de <body>. Dans un script <head> synchrone, la requête ne renvoie rien et les deux effets sont silencieusement morts.

4. La passerelle a11y doit changer, sinon elle devient silencieusement plus faible

C'est la partie facile à manquer, et c'est la raison pour laquelle cette compétence existe plutôt qu'un extrait dans website-design-system.

Une révélation laisse les titres à opacity: 0 pendant que axe s'exécute, et axe ignore les éléments qu'il considère invisibles. Leur contraste de couleur n'est soudainement plus vérifié. La suite reste verte et la passerelle est plus faible que la veille.

Forcez le mouvement réduit dans a11y.spec.ts pour que axe voie toujours la page terminée. L'extrait ci-dessous suppose la forme for (const path of PAGES) de ce fichier — ajustez-la à la boucle ou au dispositif que votre suite utilise réellement. L'appel emulateMedia avant goto, et le canari en dessous, sont les parties critiques :

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

test.use({ reducedMotion: 'reduce' }) peut ne pas être un substitut. Mesuré une fois, sur Playwright 1.60, contre la configuration d'un autre dépôt — pas celle de cette boîte à outils, dont le fichier de verrouillage résout actuellement 1.64.1. Là, la forme test.use a été acceptée sans problème tandis que le matchMedia de la page rapportait toujours no-preference ; page.emulateMedia et browser.newContext ont tous deux fonctionné dans la même exécution. Cela contredit la documentation de l'option, et le mécanisme n'a jamais été établi, donc traitez-le comme une mesure unique plutôt qu'une règle concernant Playwright. emulateMedia est utilisé ici car c'est la forme qui a effectivement été observée comme fonctionnelle.

Quelle que soit la forme que vous utilisez, prouvez qu'elle a atteint le navigateur, car l'échec est silencieux — et un appel par test est à une ligne oubliée d'une page qui conserve silencieusement la passerelle affaiblie :

// Canari. Sans lui, une passerelle qui ne fait rien ressemble exactement à une passerelle.
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches),
  'le mouvement réduit n'a jamais atteint le navigateur — la passerelle a11y n'est pas déterministe')
  .toBe(true);

5. motion.spec.ts — le test qui rend le contrat réel

Copiez templates/motion.spec.ts de cette compétence dans tests/ du site. Il se trouve ici plutôt que dans la superposition Astro new-website intentionnellement : cette superposition est copiée dans chaque structure, et ce spec vérifie un effet qu'un site vierge n'a pas — il échouerait dès le premier jour de chaque nouveau projet.

Chaque ligne ci-dessous existe à cause d'une manière spécifique dont le contrat se brise. La colonne de droite indique à quoi sert l'affirmation, pas une affirmation sur la fréquence à laquelle elle a été déclenchée :

affirmationle bug qu'elle attrape
Aucun titre n'est invisible après un défilement complettout le mode d'échec contre lequel cette compétence se prémunit
Rien au-dessus du pli n'est caché au chargementun titre qui disparaît puis réapparaît visiblement
Les cibles de révélation sont visibles sous media: printla substitution d'impression supprimée ou mal ordonnée
En mouvement réduit, rien n'est caché, scroll-behavior est auto, rien n'est animé, et la règle .reveal est forcée pour prouver que le CSS lui-même fonctionneune animation ambiante ignorant la préférence ; le défilement fluide laissé actif ; et une substitution placée avant la règle qu'elle remplace — même spécificité, donc l'original gagne et le correctif est un no-op silencieux
Les nombres de statistiques se stabilisent sur leurs valeurs publiéesquelqu'un éditant les chiffres dans le HTML

L'étape de classe forcée dans la ligne 4 n'est pas une précaution superflue. En mouvement réduit, le JS de la révélation abandonne, donc la classe de masquage n'est jamais ajoutée et la feuille de style n'est jamais exercée — sans la forcer, cette ligne passerait avec la substitution CSS cassée.

Un test « rien n'anime » doit aussi couvrir les autres animations de la page — une lueur ambiante du héros, scroll-behavior: smooth. Affirmer seulement votre propre effet rend le nom du test mensonger.

L'affirmation du compteur est une capture de contenu, pas un test d'animation. La dernière image restaure la chaîne écrite, donc elle réussit avec l'animation morte. Dites-le dans le commentaire plutôt que d'impliquer une couverture qu'il n'a pas ; échantillonner une animation rAF de 1,4 s depuis le test dépend du timing et ne vaut pas une passerelle instable.


6. Pièges qui coûtent du vrai temps de débogage

  • Une substitution de mouvement réduit doit venir APRÈS la règle qu'elle remplace. Même spécificité signifie que la dernière gagne. Placée à côté de la déclaration d'origine, elle se lit plus naturellement et ne fait rien.
  • Une bande de type marquee ne peut pas être survolée ou cliquée par Playwright. Il attend que l'élément cesse de bouger et un élément en boucle ne s'arrête jamais. Déplacez la souris par coordonnées (page.mouse.move) à la place.
  • Échantillonner une animation en boucle produit des résultats instables au retour. Une paire avant/après mesure une fenêtre contre la période de la boucle — un échantillon de 1 s d'une boucle de 90 s tombe sur le redémarrage, où la position saute en avant, environ une fois sur quatre-vingt-dix. Échantillonnez plusieurs intervalles et prenez la majorité ; un seul d'entre eux peut contenir le retour.
  • Ajouter un titre à un fichier de style existant peut en changer l'ordre de cascade. Si le nouveau bloc @media se trouve avant les règles de gabarit de la carte, celles-ci l'emportent sur les vôtres sans que vous le sachiez. Placez les substitutions de mouvement réduit après toutes les règles qu'elles doivent remplacer, dans la même feuille ou dans une feuille liée après.
  • Le mode sombre peut définir une transition sur background-color qui, combinée à la révélation, retarde l'apparition du fond du titre. Vérifiez la couleur de fond calculée et la vitesse de son animation — si elle est animée, le titre reste transparent pendant la durée de la transition de fond.
  • L'indicateur d'accessibilité aria-hidden n'est pas un état visuel. Un élément avec aria-hidden="true" n'est pas masqué visuellement ; un titre révélé avec aria-hidden semble correct mais n'est pas annoncé. Ne réutilisez pas ce attribut aux fins de la révélation.

7. Résumé de la structure

site/
├── tests/
│   └── motion.spec.ts          ← copié depuis cette compétence
├── src/
│   └── components/
│       ├── MotionClient.astro  ← les scripts ci-dessus, intégrés ou liés
│       └── …
└── astro.config.mjs            ← pas de modifications nécessaires

Le fichier de test suppose que vous avez une structure de page où les titres de section utilisent une classe comme .section-title et les nombres de statistiques une classe comme .hero-stat-number. Ajustez les sélecteurs dans le bloc CONFIG en haut de motion.spec.ts avant la première exécution.


8. Crédits et licence

Extrait de modèles de sites de production. Distribué dans le cadre de la boîte à outils karero/new-website. Licence MIT.

Individual skills in this repo

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

Skills associés