Communitygithub.com

karero/website-motion

Zwei Effekte, bewusst eingesetzt. Bewegung ist der Punkt, an dem eine Website maschinell wirkt:

Was ist website-motion?

Dieses Skill für AI-Agenten (kompatibel mit Claude Code, Cursor, Codex und ähnlichen Tools) fügt einer Website zwei gezielte Animationseffekte hinzu – einen Scroll-Reveal für Abschnittsüberschriften und einen Zahlen-Count-up für Helden-Statistiken – und setzt dabei einen strengen Vertrag durch: Kein Effekt darf Inhalte unzugänglich machen. Die Effekte werden nur bei langen Seiten empfohlen: Der Reveal hebt Überschriften hervor, während sie ins Bild scrollen, und der Count-up lässt statische Zahlen (z. B. Teilnehmerzahlen oder Projektanzahlen) animiert hochzählen. Das Skill enthält ein eigenes Test-Suite (`motion.spec.ts`), das überprüft, ob alle Inhalte sichtbar bleiben, die Barrierefreiheit (a11y) gewahrt ist und sowohl reduzierter Bewegungsmodus als auch Druckausgabe korrekt behandelt werden. Es ist für das Astro-Framework konzipiert und erfordert eine manuelle Konfiguration der CSS-Selektoren, um mit dem tatsächlichen Markup der Website zu funktionieren.

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

In Ihrer bevorzugten KI fragen

Öffnet einen neuen Chat, in dem dieser Agent-Skill bereits geladen ist.

Dokumentation

Was macht website-motion?

Zwei Effekte, bewusst eingesetzt. Bewegung ist der Punkt, an dem eine Website maschinell wirkt: Auf jede Karte und jeden Absatz angewendet, liest es sich wie eine Vorlage, und es ist der einfachste Weg, Inhalte durch einen Barrierefreiheits-Bug zu verlieren, den niemand bemerkt. Dieses Skill liefert die beiden, die sich ihren Platz verdienen – und, was noch wichtiger ist – den Vertrag, der sie sicher macht.

Füge dies nur hinzu, wenn die Seite tatsächlich lang ist. Bei einer Drei-Abschnitts-Seite ist ein Reveal nur Lärm. Der Count-up braucht Zahlen, die ein Besucher stoppen und lesen sollte (Teilnehmerzahlen, Jahre im Betrieb, ausgelieferte Projekte), keine Dekoration.

Voraussetzung: Zuerst website-qa grün. Dies fügt eine Testdatei zu dieser Suite hinzu und ändert, wie das a11y-Gate läuft.

Die unten stehenden Klassennamen sind Beispiele, nicht die des Gerüsts

.section-title, .section-subtitle, .hero-stat-number und .hero kommen in Astro-Vorlagen von new-website null mal vor. Sie stammen von einer echten Website, aus der dies extrahiert wurde. Nichts hier funktioniert, bis du jeden Selektor auf dein eigenes Markup zeigst – das CSS, die beiden Skripte und den CONFIG-Block am Anfang von templates/motion.spec.ts. Der Spec überspringt mit einer angegebenen Begründung, wenn ein Selektor leer gelassen wird, sodass eine unkonfigurierte Kopie „nicht anwendbar“ meldet, anstatt auf eine Weise zu fehlschlagen, die wie ein Produktfehler aussieht.


0. Der Einzeiler-Check, der keine Motion ist

Bevor du etwas hinzufügst, miss den Helden:

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

Wenn min-height: 100vh bindet – der Inhalt des Helden ist kürzer als der Viewport – beginnt der nächste Abschnitt genau am Seitenfalz und die Seite liest sich wie ein Splashscreen. Reduziere die Untergrenze, bis der nächste Abschnitt über den Rand lugt.

Kopiere keine Zahl von einer anderen Website. Der richtige Wert ist eine Funktion der Inhaltshöhe des Helden. Wenn der Inhalt den Viewport bereits überschreitet, bewirkt die Änderung von min-height bei dieser Bildschirmgröße gar nichts, und der eigentliche Hebel ist das Entfernen eines Absatzes aus dem Helden. Messe, dann entscheide.


1. Der Vertrag

Alles unten Existierende dient einem Versprechen: Kein Effekt darf Inhalte unerreichbar machen. Drei Regeln, in Prioritätsreihenfolge.

  1. Der verborgene Zustand wird von JS hinzugefügt, niemals in HTML verfasst. Ein Besucher ohne JS, oder einer, der mitten auf der Seite landet, muss die fertige Seite sehen. Das Skript fügt die verbergende Klasse selbst hinzu, und zwar nur für Elemente, die sich derzeit unter dem Falz befinden.

  2. prefers-reduced-motion: reduce endet mit allem sichtbar und nichts in Bewegung – einschließlich Animationen, die älter als dieses Skill sind. Das Astro-Gerüst liefert html { scroll-behavior: smooth } ohne Überschreibung, daher ist dies ein Schritt, den du tatsächlich ausführen musst, keine Eigenschaft, die du erbst:

    /* NACHDEM den Regeln, die es überschreibt – siehe §6, das ist die ganze Falle */
    @media (prefers-reduced-motion: reduce) {
      html { scroll-behavior: auto; }
      /* plus alle Umgebungsanimationen, die diese Seite ausführt */
    }
    
  3. Der Druck ist ein dritter Zustand. Er scrollt nie, daher feuert IntersectionObserver nie und eine enthüllte Überschrift wird leer gedruckt.


2. Scroll-Reveal – nur Abschnittsüberschriften

@media (prefers-reduced-motion: no-preference) {
  /* Kein Übergang im verborgenen Zustand. `reveal` wird nach dem ersten Paint hinzugefügt, daher würde ein Übergang hier eine bereits gezeichnete Überschrift für eine halbe Sekunde AUSBLENDEN lassen, bevor sie einblenden kann – sichtbar für jeden, der direkt nach dem Laden scrollt.
     Mit dem Übergang im sichtbaren Zustand wird das Verbergen sofort ausgeführt und nur der Reveal animiert. */
  .reveal { opacity: 0; transform: translateY(12px); }
  .reveal.is-visible {
    opacity: 1; transform: none;
    transition: opacity 0.55s ease-out, transform 0.55s ease-out;
  }
  /* Gleiche Spezifität wie die obige Regel und SPÄTER im Stylesheet, damit die Kurzschreibweise dort die Verzögerung nicht auf null zurücksetzen kann. */
  .reveal-delay.is-visible { transition-delay: 0.09s; }
}
/* Der Druck scrollt nie, daher feuert der Observer nie. */
@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) {
    // Alles, was bereits auf dem Bildschirm ist, wird in Ruhe gelassen – es jetzt zu verbergen, würde es beim Laden aufblitzen und wieder einblenden lassen.
    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);
  });
})();

Nur Überschriften. Der Untertitel folgt einen Schlag später, sodass das Paar als eine Bewegung gelesen wird – das ist, was „gestaffelt“ hier bedeuten sollte. Dies auf Karten, Listenelemente und Absätze anzuwenden, ist das Erkennungsmerkmal einer KI-generierten Website.


3. Count-up der Helden-Statistiken

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

  function countUp(el) {
    // Das Schreiben von textContent glättet untergeordnetes Markup dauerhaft – ein gestyltes
    // <span>+</span> innerhalb der Zahl würde die Animation nicht überleben.
    if (el.children.length) return;

    var finalText = el.textContent.trim();
    // Ziffern, ein optionales Gruppentrennzeichen ("," im Englischen, "." im Deutschen), ein optionales
    // nachgestelltes Symbol. Alles andere wird genau so belassen, wie es verfasst wurde: Dies sind
    // veröffentlichte Angaben, und "4.9" darf niemals auf 49 hochzählen.
    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] || '';

    // Spiegle die VERFASSTE Gruppierung wider, nicht eine Laufzeit-Lokalisierung. Die eingefrorene
    // Breite unten wird anhand der verfassten Zeichenfolge gemessen, daher würde die Wiedergabe von "1,200"
    // wo die Seite "1.200" sagt – oder die Gruppierung einer Zahl, die die Seite ungruppiert ließ – eine
    // andere Breite erzeugen, was genau das Zeilenrücken ist, das der Einfrieren verhindern soll.
    function render(v) {
      var digits = String(v);
      return (groupSep ? digits.replace(/\B(?=(\d{3})+(?!\d))/g, groupSep) : digits) + suffix;
    }

    // Friere zuerst die Box ein. "0+" ist viel schmaler als "1.200+", und die Statistik wird
    // normalerweise durch ihre Zahl und nicht durch ihre Beschriftung dimensioniert, daher würde ein nicht eingefrorener Count-up die gesamte Zeile in jedem Frame seitwärts schieben.
    // Merke dir jede vorhandene inline-min-width auf dem Element – sie beim Abschluss zu löschen, würde still eine spätere Styling-Änderung rückgängig machen, die jemand anderes vorgenommen hat.
    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;
    }
    // Ein Druck, der mitten im Countup ausgelöst wird, würde einen Zwischenwert erfassen – eine Zahl,
    // die die Website nicht behauptet. Vertragsregel 3 deckt den Reveal ab; dies ist die Hälfte des Count-ups.
    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); });
})();

Die endgültigen Werte bleiben im HTML, sodass Besucher ohne JS und Crawler die echten Zahlen sehen und die Animation sie nur vorübergehend ersetzt.

min-width funktioniert nur bei einer Block-Box. Wenn die Statistikzahl auf display: inline gesetzt ist, ist das Einfrieren ein stilles No-Op und die Zeile springt trotzdem. Überprüfe den berechneten Wert oder setze display: inline-block.

Führe beide Skripte aus, nachdem das DOM geparst wurde – defer, ein Modul oder am Ende von <body>. In einem synchronen <head>-Skript gibt die Abfrage nichts zurück und beide Effekte sind still tot.

4. Das a11y-Gate muss sich ändern, sonst wird es leise schwächer

Dies ist der Teil, den man leicht übersieht, und der Grund, warum dieses Skill existiert, anstatt nur ein Schnipsel in website-design-system zu sein.

Ein Reveal hinterlässt Überschriften mit opacity: 0, während axe läuft, und axe überspringt Elemente, die es als unsichtbar betrachtet. Ihre Farbkontraste werden nicht mehr überprüft. Die Suite bleibt grün und das Gate ist schwächer als am Tag zuvor.

Erzwinge reduzierten Bewegungsmodus in a11y.spec.ts, damit axe immer die fertige Seite sieht. Der folgende Ausschnitt geht von der for (const path of PAGES)-Struktur dieser Datei aus – passe ihn an die Schleife oder das Fixture an, das deine Suite tatsächlich verwendet. Der emulateMedia-Aufruf vor goto und der Canary darunter sind die tragenden Teile:

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

test.use({ reducedMotion: 'reduce' }) ist möglicherweise kein Ersatz. Einmal gemessen, bei Playwright 1.60, gegen die Konfiguration eines anderen Repos – nicht die dieses Toolkits, dessen Lockfile derzeit 1.64.1 auflöst. Dort wurde die test.use-Form ohne Beschwerde akzeptiert, während die matchMedia der Seite immer noch no-preference meldete; page.emulateMedia und browser.newContext funktionierten beide im selben Durchlauf. Das widerspricht der Dokumentation der Option, und der Mechanismus wurde nie ermittelt, also behandle es als eine Messung, nicht als eine Regel über Playwright. emulateMedia wird hier verwendet, weil es die Form war, die tatsächlich funktionierte.

Unabhängig davon, welche Form du verwendest, beweise, dass sie den Browser erreicht hat, denn der Fehler ist still – und ein testweiser Aufruf ist nur eine vergessene Zeile von einer Seite entfernt, die leise das geschwächte Gate beibehält:

// Canary. Ohne ihn sieht ein Gate, das nichts tut, genauso aus wie ein Gate.
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches),
  'reduzierte Bewegung hat den Browser nie erreicht – das a11y-Gate ist nicht deterministisch')
  .toBe(true);

5. motion.spec.ts – der Test, der den Vertrag real macht

Kopiere templates/motion.spec.ts dieses Skills in das tests/-Verzeichnis der Website. Es lebt hier und nicht im Astro-Overlay von new-website, und das mit Absicht: Dieses Overlay wird in jedes Gerüst kopiert, und dieser Spec behauptet einen Effekt, den eine frische Website nicht hat – er würde am ersten Tag jedes neuen Projekts fehlschlagen.

Jede Zeile unten existiert wegen einer bestimmten Art und Weise, wie der Vertrag gebrochen werden kann. Die rechte Spalte gibt an, wofür die Behauptung gedacht ist, keine Aussage darüber, wie oft sie ausgelöst wurde:

BehauptungDer Fehler, den sie abfängt
Keine Überschrift ist nach einem vollständigen Durchscrollen unsichtbarDer gesamte Fehlermodus, gegen den dieses Skill schützt
Nichts über dem Falz ist beim Laden verborgenEine Überschrift, die sichtbar ausblendet und wieder einblendet
Reveal-Ziele sind unter media: print sichtbarDie Druck-Überschreibung wurde gelöscht oder falsch angeordnet
Bei reduzierter Bewegung ist nichts verborgen, scroll-behavior ist auto, nichts animiert, und die .reveal-Regel wird erzwungen angewendet, um zu beweisen, dass das CSS selbst funktioniertEine Umgebungsanimation ignoriert die Präferenz; sanftes Scrollen bleibt eingeschaltet; und eine Überschreibung, die vor der zu überschreibenden Regel platziert wurde – gleiche Spezifität, also gewinnt die ursprüngliche und die Reparatur ist ein stilles No-Op
Statistikzahlen stabilisieren sich auf ihren veröffentlichten WertenJemand hat die Zahlen im HTML bearbeitet

Der erzwungene Klassen-Schritt in Zeile 4 ist keine doppelte Absicherung. Bei reduzierter Bewegung bricht das Reveal-JS ab, daher wird die verbergende Klasse nie hinzugefügt und das Stylesheet nie ausgeführt – ohne sie zu erzwingen, würde diese Zeile bestehen, selbst wenn die CSS-Überschreibung kaputt wäre.

Ein „nichts animiert“-Test muss auch die anderen Animationen der Seite abdecken – einen Umgebungs-Helden-Glow, scroll-behavior: smooth. Nur den eigenen Effekt zu behaupten, macht den Namen des Tests zur Lüge.

Die Count-up-Behauptung ist ein Inhalts-Snapshot, kein Animationstest. Der letzte Frame stellt die verfasste Zeichenfolge wieder her, daher besteht er, selbst wenn die Animation tot ist. Sag dies im Kommentar, anstatt eine Abdeckung vorzutäuschen, die sie nicht hat; das Abtasten einer 1,4-s-rAF-Animation von der Testseite aus ist zeitabhängig und ein flaky Gate nicht wert.


6. Fallstricke, die echte Debug-Zeit gekostet haben

  • Eine reduced-motion-Überschreibung muss NACH der zu überschreibenden Regel kommen. Gleiche Spezifität bedeutet, dass die spätere gewinnt. Platziert neben der ursprünglichen Deklaration liest sie sich natürlicher und tut nichts.
  • Ein Laufband-artiger Streifen kann von Playwright nicht gehovert oder angeklickt werden. Es wartet darauf, dass das Element aufhört, sich zu bewegen, und ein sich wiederholendes Element tut das nie. Steuere die Maus per Koordinate (page.mouse.move) stattdessen.
  • Das Abtasten einer sich wiederholenden Animation wird am Umbruch flaky. Ein einzelnes Vorher/Nachher-Paar misst ein Fenster gegen die Schleifenperiode – eine 1-s-Probe einer 90-s-Schleife trifft etwa einmal in neunzig Läufen auf den Neustart, wo die Position nach vorne springt. Nimm mehrere Intervalle und eine Mehrheitsentscheidung; nur eines davon kann den Umbruch enthalten.
  • Das Hinzufügen einer Überschrift …

[gekürzt für die Übersetzung]

Individual skills in this repo

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

Verwandte Skills