Einblendungen laufen beim Scrollen, ohne Skript
Umut: "Effekte gibts keine, sind die aktiv?" Nachgemessen: die Zeigereffekte liefen alle. Die Einblendungen gab es seit Schema v3 - nur benutzt hatte sie niemand, weil eine Animation beim Laden unterhalb der Falz wertlos ist. Mit animation-timeline: view() laufen dieselben Keyframes, wenn der Abschnitt in den Blick kommt - hinter der Bewegungsschranke, hinter @supports (Firefox und Safari behalten das Einblenden beim Laden) und mit einem Bereich, der endet, bevor der Abschnitt in der Mitte steht. Dabei hat axe einen Fehler gefunden, den ich selbst gebaut hatte: eine Scroll-Zeitachse ist umkehrbar, also stand alles unterhalb der Falz dauerhaft am Anfang der Kurve - bei opacity .35 blass, bei .85 immer noch 3,86:1 statt 7,1:1. Auf dem Scrollweg wird deshalb nur Bewegung animiert, keine Deckkraft. Benutzt wird es jetzt auch: alle drei Beitragsvorlagen bringen die Einblendung je Abschnitt mit. Geprueft: 400 Tests unter PHP 8.2 und 8.4, Editortest, Browserpruefung, axe ohne Verstoss auf Startseite, Projekteseite und Beitrag - vor und nach dem Durchscrollen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d6c1a17af
commit
f0b634f0c1
5 files changed
+114
-13
No files matched your search
@@ -440,6 +440,20 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||
|
||||
## ADR 052 – Einblenden beim Scrollen, ohne eine Zeile JavaScript (13.09.2026)
|
||||
|
||||
Umut: „Effekte gibt es keine, sind die aktiv aktuell?“ Nachgemessen statt geraten: die Zeigereffekte liefen alle (Karte −8 px, Bild 1,06×, Symbolkarte −4 px, Knopf −2 px, Titel in Akzentfarbe). Die **Einblendungen beim Laden** gab es seit Dokumentschema v3 – und **benutzt hatte sie niemand**. Ein Werkzeug, das im Kasten bleibt, ist kein Werkzeug.
|
||||
|
||||
Der Grund, warum es im Kasten blieb, war ein echter: eine Animation, die beim *Laden* läuft, ist unterhalb der Falz wertlos – sie ist vorbei, bevor jemand hinsieht. Öffentliche Seiten laufen ohne JavaScript, also gab es bisher nichts, was den Scrollstand kennt.
|
||||
|
||||
**Inzwischen kennt CSS ihn.** `animation-timeline: view()` bindet den Ablauf einer Animation an die Sichtbarkeit des Elements: dieselben Keyframes, aber sie laufen, wenn der Abschnitt in den Blick kommt. Dreifach abgesichert: hinter der Bewegungsschranke, hinter `@supports` (Firefox und Safari behalten heute die Fassung von vorher – Einblenden beim Laden, nichts fehlt), und mit einem Bereich, der endet, bevor der Abschnitt in der Bildschirmmitte steht.
|
||||
|
||||
**Und dann hat die Prüfung etwas gefunden, das ich selbst gebaut hatte.** Eine Scroll-Zeitachse ist **umkehrbar**: ein Abschnitt außerhalb des Blicks steht wieder am Anfang der Kurve. Mit der ursprünglichen Kurve (`opacity: .35`) hieß das: alles unterhalb der Falz ist dauerhaft blass – und blasser Text ist kein Effekt, sondern schlechterer Kontrast. axe meldete prompt fünfzehn Verstöße. Der Versuch mit 0,85 half nicht: aus `#3d4d5c` wird bei 85 Prozent `#78838d`, also 3,86:1 statt 7,1:1.
|
||||
|
||||
**Auf dem Scrollweg wird deshalb keine Deckkraft animiert, nur Bewegung.** 20 px für „rise“, 10 px für „fade“, Deckkraft bleibt 1. Der Effekt trägt trotzdem, und der Text ist an jeder Stelle lesbar – auch wenn er gerade nicht zu sehen ist. Beim Laden (ohne Scroll-Zeitachse) bleiben die alten Kurven mit Deckkraft, weil sie dort einmalig laufen und nicht zurückspulen.
|
||||
|
||||
**Benutzt wird es jetzt auch:** alle drei Beitragsvorlagen bringen die Einblendung je Abschnitt mit, und der Nachbau von kant-it.de hat sie auf jeder Seite – im Hero gestaffelt, darunter je Abschnitt.
|
||||
|
||||
## ADR 051 – Vier Darstellungen für dieselbe Beitragsliste (13.09.2026)
|
||||
|
||||
Umut wollte auch für die Auflistung mehrere Formen. Dieselben Daten, dasselbe Markup – nur eine Klasse wechselt. Eine Darstellung kostet dadurch ein paar Regeln CSS und keine Zeile PHP.
|
||||
|
||||
@@ -28,6 +28,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
|
||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
||||
- **Einblendungen laufen beim Scrollen** (ADR 052): `animation-timeline: view()` hinter Bewegungsschranke und `@supports`; auf dem Scrollweg nur Bewegung, keine Deckkraft, damit Text außerhalb des Blicks lesbar bleibt. Die drei Beitragsvorlagen bringen sie mit.
|
||||
- **Vier Darstellungen der Beitragsliste** (ADR 051): Karten, Liste, Kompakt und Wechselseitig – am Baustein einstellbar, dieselben Daten, nur andere Regeln.
|
||||
- **Drei Aufbauten für neue Beiträge** (ADR 050): „Bildkopf“, „Klassisch“ und „Magazin“, wählbar beim Anlegen. Startpunkte, keine Schablonen – jeder Block bleibt änderbar.
|
||||
- **Dokumentschema v18** (ADR 049): Baustein „Beitragsnavigation“ – Vor/Zurück zwischen Beiträgen derselben Kategorie, vom Renderer gefüllt, ohne Umbruch am Rand. Dazu Autorotation für den Slider (Sekunden, eigenes Skript nur auf Seiten mit einem solchen Slider) und Blättern in der Beitragsliste.
|
||||
|
||||
@@ -66,3 +66,43 @@
|
||||
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
|
||||
.button:hover { transform: translateY(-2px) }
|
||||
}
|
||||
|
||||
/* Einblenden beim Hineinscrollen, ergänzt am 13.09.2026.
|
||||
|
||||
Bisher liefen die Einblendungen beim Laden der Seite - was unterhalb der Falz stand, war
|
||||
fertig animiert, bevor es jemand gesehen hat. Mit einer Scroll-Zeitachse läuft dieselbe
|
||||
Animation genau dann, wenn das Element in den Blick kommt, und zwar ohne eine Zeile
|
||||
JavaScript: `animation-timeline: view()` bindet den Ablauf an die Sichtbarkeit.
|
||||
|
||||
Drei Bedingungen, alle bewusst:
|
||||
- Hinter der Bewegungsschranke: wer weniger Bewegung eingestellt hat, bekommt keine.
|
||||
- Hinter `@supports`: Browser ohne Scroll-Zeitachse (heute Firefox und Safari) behalten die
|
||||
bisherige Fassung - die Animation läuft beim Laden. Niemand sieht deshalb weniger Inhalt.
|
||||
- `animation-range` endet früh (`cover 26%`): die Bewegung ist vorbei, bevor das Element in
|
||||
der Mitte des Bildschirms steht. Eine Einblendung, die beim Lesen noch läuft, stört. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
/* Eigene Kurven für den Scrollweg: **nur Bewegung, keine Deckkraft**.
|
||||
Eine Scroll-Zeitachse ist umkehrbar - ein Abschnitt außerhalb des Blicks steht wieder am
|
||||
Anfang. Alles unterhalb der Falz wäre also dauerhaft blass, und blasser Text ist nicht
|
||||
weniger Effekt, sondern schlechterer Kontrast: #3d4d5c bei 85 Prozent ergibt 3,86:1
|
||||
statt 7,1:1 - axe meldet das zu Recht. Der Weg trägt den Effekt allein. */
|
||||
@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }
|
||||
@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }
|
||||
|
||||
.fx-entrance-fade, .fx-entrance-rise, .fx-entrance-sink, .fx-entrance-left,
|
||||
.fx-entrance-right, .fx-entrance-zoom, .fx-entrance-flip, .fx-entrance-blur {
|
||||
animation-name: kant-scroll-rise;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 10% cover 28%;
|
||||
animation-duration: 1ms; /* Die Zeitachse bestimmt den Ablauf, nicht die Uhr. */
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.fx-entrance-fade { animation-name: kant-scroll-fade }
|
||||
/* Die Verzögerungsstufen sind auf einer Scroll-Zeitachse eine Verschiebung im Weg,
|
||||
nicht in der Zeit - sonst würden sie gar nicht wirken. */
|
||||
.fx-delay-short { animation-range: entry 16% cover 32% }
|
||||
.fx-delay-medium { animation-range: entry 22% cover 36% }
|
||||
.fx-delay-long { animation-range: entry 28% cover 40% }
|
||||
}
|
||||
}
|
||||
+21
-11
@@ -78,7 +78,7 @@ final class PostLayouts
|
||||
'backgroundColor' => '@ink', 'color' => '#ffffff', 'textAlign' => 'center',
|
||||
'display' => 'flex', 'alignItems' => 'flex-end', 'justifyContent' => 'center',
|
||||
'minHeight' => '58vh', 'padding' => self::spacing('110px', '52px', '0px', '0px'),
|
||||
]),
|
||||
], [], ['entrance' => 'fade']),
|
||||
self::narrow('text', [
|
||||
self::heading('text-h', 'Über den Beitrag', 2, ['fontSize' => '28px', 'fontWeight' => '800', 'color' => '@ink']),
|
||||
self::text('text-1', 'Schreibe hier den ersten Absatz. Er beantwortet am besten die Frage, warum jemand weiterlesen sollte.'),
|
||||
@@ -88,9 +88,9 @@ final class PostLayouts
|
||||
self::badge('galerie-label', 'Bildergalerie', true),
|
||||
self::heading('galerie-h', 'Eindrücke', 2, ['fontSize' => '30px', 'fontWeight' => '800', 'color' => '@ink']),
|
||||
self::gallery('galerie-bilder', 2),
|
||||
], ['backgroundColor' => '@paper', 'padding' => self::spacing('60px', '60px', '0px', '0px')]),
|
||||
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')]),
|
||||
self::section('aufruf', [self::cta('aufruf-band')], ['backgroundColor' => '@ink', 'color' => '#ffffff', 'padding' => self::spacing('62px', '62px', '0px', '0px')]),
|
||||
], ['backgroundColor' => '@paper', 'padding' => self::spacing('60px', '60px', '0px', '0px')], [], ['entrance' => 'rise']),
|
||||
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
self::section('aufruf', [self::cta('aufruf-band')], ['backgroundColor' => '@ink', 'color' => '#ffffff', 'padding' => self::spacing('62px', '62px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -103,13 +103,13 @@ final class PostLayouts
|
||||
self::heading('kopf-titel', $title, 1, ['fontSize' => '42px', 'fontWeight' => '800', 'lineHeight' => 1.15, 'letterSpacing' => '-0.8px']),
|
||||
self::text('kopf-vorspann', 'Ein Vorspann in zwei, drei Sätzen. Er steht für sich und sagt, worum es geht – wer nur ihn liest, weiß Bescheid.', ['fontSize' => '20px', 'lineHeight' => 1.6]),
|
||||
], '70px', '30px'),
|
||||
self::section('bild', [self::image('bild-1')], ['padding' => self::spacing('0px', '10px', '0px', '0px')]),
|
||||
self::section('bild', [self::image('bild-1')], ['padding' => self::spacing('0px', '10px', '0px', '0px')], [], ['entrance' => 'zoom']),
|
||||
self::narrow('text', [
|
||||
self::text('text-1', 'Der erste Absatz des Haupttextes. Ab hier darf es ausführlich werden.'),
|
||||
self::heading('text-h', 'Ein Zwischentitel', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('34px', '12px', '0px', '0px')]),
|
||||
self::text('text-2', 'Zwischentitel machen einen langen Text überhaupt erst lesbar. Setze alle drei bis fünf Absätze einen.'),
|
||||
]),
|
||||
self::section('weiter', [self::nav('weiter-nav')], ['backgroundColor' => '@paper', 'padding' => self::spacing('50px', '50px', '0px', '0px')]),
|
||||
self::section('weiter', [self::nav('weiter-nav')], ['backgroundColor' => '@paper', 'padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ final class PostLayouts
|
||||
self::text('eck-daten', "Kunde: noch einzutragen\nLeistung: noch einzutragen\nJahr: noch einzutragen", ['fontSize' => '15px']),
|
||||
]],
|
||||
]],
|
||||
], ['backgroundColor' => '@paper', 'padding' => self::spacing('72px', '48px', '0px', '0px')]),
|
||||
], ['backgroundColor' => '@paper', 'padding' => self::spacing('72px', '48px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
self::narrow('text', [
|
||||
self::text('text-1', 'Der Einstieg in den Beitrag. Was war die Aufgabe, und warum war sie nicht trivial?'),
|
||||
self::heading('text-h', 'Der Weg', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
|
||||
@@ -140,16 +140,26 @@ final class PostLayouts
|
||||
self::heading('text-h2', 'Das Ergebnis', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
|
||||
self::text('text-3', 'Was sich seitdem geändert hat – am besten ohne Superlative.'),
|
||||
]),
|
||||
self::section('galerie', [self::gallery('galerie-bilder', 3)], ['backgroundColor' => '@paper', 'padding' => self::spacing('56px', '56px', '0px', '0px')]),
|
||||
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')]),
|
||||
self::section('galerie', [self::gallery('galerie-bilder', 3)], ['backgroundColor' => '@paper', 'padding' => self::spacing('56px', '56px', '0px', '0px')], [], ['entrance' => 'rise']),
|
||||
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bausteine
|
||||
|
||||
private static function section(string $id, array $children, array $desktop = [], array $mobile = []): array
|
||||
/**
|
||||
* Ein Abschnitt, wahlweise mit Einblendung.
|
||||
*
|
||||
* Die Einblendung läuft beim Hineinscrollen, wo der Browser eine Scroll-Zeitachse kennt, und
|
||||
* sonst beim Laden - beides ohne Skript, beides hinter der Bewegungsschranke. Deshalb darf
|
||||
* sie auch weiter unten stehen, ohne ins Leere zu laufen.
|
||||
*/
|
||||
private static function section(string $id, array $children, array $desktop = [], array $mobile = [], array $effects = []): array
|
||||
{
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children];
|
||||
if ([] !== $effects) {
|
||||
$block['layout'] = ['effects' => $effects];
|
||||
}
|
||||
if ([] !== $desktop || [] !== $mobile) {
|
||||
$block['style'] = array_filter(['desktop' => $desktop, 'mobile' => $mobile]);
|
||||
}
|
||||
@@ -164,7 +174,7 @@ final class PostLayouts
|
||||
'maxWidth' => '820px',
|
||||
'margin' => self::spacing('0px', '0px', 'auto', 'auto'),
|
||||
'padding' => self::spacing($top, $bottom, '0px', '0px'),
|
||||
]);
|
||||
], [], ['entrance' => 'rise']);
|
||||
}
|
||||
|
||||
private static function heading(string $id, string $text, int $level, array $style = []): array
|
||||
|
||||
+38
-2
@@ -767,6 +767,40 @@ final class CmsTest extends TestCase
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
public function testEntranceEffectsRunOnScrollWhereTheBrowserCanAndOnLoadOtherwise(): void
|
||||
{
|
||||
// Umut: „Effekte gibt es keine“. Es gab sie - aber nur beim Zeigen, nicht beim Laden, und
|
||||
// eine Einblendung beim Laden ist unterhalb der Falz wertlos: sie ist vorbei, bevor
|
||||
// jemand hinsieht. Mit einer Scroll-Zeitachse läuft dieselbe Animation, wenn der
|
||||
// Abschnitt in den Blick kommt - ohne eine Zeile JavaScript.
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
|
||||
self::assertStringContainsString('@supports (animation-timeline: view())', $css);
|
||||
self::assertStringContainsString('animation-timeline: view()', $css);
|
||||
|
||||
// Beides steht hinter der Bewegungsschranke, und der Rückfall ist die Fassung von vorher:
|
||||
// ein Browser ohne Scroll-Zeitachse zeigt alles, nur eben beim Laden animiert.
|
||||
$geschuetzt = explode('@media (prefers-reduced-motion: no-preference) {', $css);
|
||||
self::assertStringNotContainsString('animation-timeline', $geschuetzt[0], 'Keine Bewegung außerhalb der Schranke.');
|
||||
self::assertSame(1, substr_count($css, '@supports (animation-timeline: view())'));
|
||||
|
||||
// Der wichtigste Teil, und er ist mit Messen bezahlt: auf dem Scrollweg wird **keine
|
||||
// Deckkraft** animiert. Eine Scroll-Zeitachse ist umkehrbar, also stünde alles unterhalb
|
||||
// der Falz dauerhaft am Anfang der Kurve - bei Deckkraft 0,85 wird aus #3d4d5c ein
|
||||
// #78838d, und damit 3,86:1 statt 7,1:1. axe hat genau das gemeldet.
|
||||
self::assertStringContainsString('@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }', $css);
|
||||
self::assertStringContainsString('@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }', $css);
|
||||
$scrollteil = explode('@supports (animation-timeline: view())', $css)[1];
|
||||
self::assertStringNotContainsString('opacity', explode('.fx-delay-long', $scrollteil)[0], 'Auf dem Scrollweg bleibt der Text voll deckend.');
|
||||
|
||||
// Und die drei Beitragsvorlagen bringen die Einblendung von sich aus mit.
|
||||
foreach (array_keys(PostLayouts::all()) as $key) {
|
||||
$document = PostLayouts::document($key, 'Ein Beitrag');
|
||||
$mitEffekt = array_filter($document['blocks'], static fn (array $block): bool => isset($block['layout']['effects']['entrance']));
|
||||
self::assertCount(count($document['blocks']), $mitEffekt, $key.': jeder Abschnitt blendet ein.');
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
}
|
||||
|
||||
public function testANewPostStartsFromOneOfThreeLayouts(): void
|
||||
{
|
||||
// Umut wollte mehrere Aufbauten zur Auswahl. Ein leeres Dokument ist für eine Seite
|
||||
@@ -911,9 +945,11 @@ final class CmsTest extends TestCase
|
||||
// Die Bewegung steht vollständig hinter der Bewegungsschranke, der Hinweis selbst nicht:
|
||||
// wer weniger Bewegung eingestellt hat, sieht ihn trotzdem.
|
||||
$effects = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
|
||||
// Der erste Abschnitt steht vor der ersten Schranke; alles dahinter ist geschützt.
|
||||
$guarded = explode('@media (prefers-reduced-motion: no-preference) {', $effects);
|
||||
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', end($guarded));
|
||||
self::assertStringContainsString('transform: scale(1.06)', end($guarded));
|
||||
$geschuetzt = implode('', array_slice($guarded, 1));
|
||||
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $geschuetzt);
|
||||
self::assertStringContainsString('transform: scale(1.06)', $geschuetzt);
|
||||
self::assertStringNotContainsString('transform: translateY(-8px)', $guarded[0], 'Keine Bewegung außerhalb der Schranke.');
|
||||
|
||||
// Und über die Tastatur erreichbar: der Zustand hängt auch an :focus-within.
|
||||
|
||||
Reference in new issue
Block a user