diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0dcea44..5870cff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -440,6 +440,31 @@ 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 047 – Ein Slider, der von selbst weiterblättert (13.09.2026) + +Umut wollte die Autorotation. Die Regel „öffentliche Seiten laufen ohne JavaScript“ steht seit dem ersten Tag, und sie bleibt – **mit einer Ausnahme, die es schon gab**: die Tabs laden ein kleines Skript, aber nur auf Seiten, die Tabs enthalten. Der Slider folgt genau diesem Muster. + +**Die Einstellung sind Sekunden, kein Häkchen.** 0 heißt aus, unter drei Sekunden wäre es keine Hilfe mehr, sondern Hektik. Die Zahl steht im Baustein, nicht im Skript. + +**Was das Skript nicht tut, ist der interessantere Teil:** +- Es läuft **gar nicht**, wenn das System weniger Bewegung verlangt. +- Es hört **endgültig** auf, sobald jemand tippt, klickt, wischt oder von Hand scrollt. Nicht „pausiert kurz“ – aus. Wer selbst blättert, hat entschieden. +- Es pausiert, solange der Zeiger darin ist, solange etwas darin den Fokus hat, solange der Tab im Hintergrund liegt und solange der Slider nicht zu sehen ist. + +**Ohne das Skript bleibt alles benutzbar**: wischen, scrollen, Tastatur, Punkte. Es kommt nur eine Bequemlichkeit dazu, es hängt nichts davon ab. + +**Und ein Fehler beim Bauen, der zeigt, warum man so etwas nachmisst:** die erste Fassung rechnete das Sprungziel aus `offsetLeft` und zog zusätzlich den Offset des Sliders ab – bei einem Slider, der selbst der Bezugsrahmen ist, kam dabei −78 heraus, also gar keine Bewegung. Gemessen wird jetzt über die Bildschirmrechtecke. Der Test dazu ist kein Schnappschuss, sondern ein Verlauf: 0 → 666 → 1332 → 1998 in drei Schritten, und nach einem Mausrad-Ereignis bleibt der Wert stehen. + +## ADR 048 – Eine Beitragsliste darf blättern (13.09.2026) + +Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig, und es widersprach der eigenen Regel aus Arbeitspaket M: *eine feste Obergrenze ist kein Blättern, sie verschweigt, dass etwas fehlt.* Für die Verwaltung war das behoben, für die Beitragsliste einer Seite nicht. + +Der Unterschied ist absichtlich und heißt jetzt `paginate`: eine **Auswahl** auf der Startseite zeigt die neuesten vier und hört auf – dort will niemand blättern, dort will man weiter zur Übersicht. Eine **Übersichtsseite** blättert und sagt dabei, wie viele es insgesamt sind. + +Zwei Kleinigkeiten, die dabei wichtig waren: eine blätternde Liste nimmt die Seitenzahl aus der Anfrage, eine Auswahl immer die erste – sonst würden zwei Listen auf derselben Seite einander umblättern. Und eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren. + +**Beim Bauen zum zweiten Mal dieselbe Falle:** die Seitenzahl war in der Schließung, die die Blöcke durchläuft, nicht gebunden – genau wie vorgestern beim Filter für Überschriften. Diesmal fiel es sofort auf, weil die Seite mit 500 antwortete statt still das Falsche zu tun. + ## ADR 046 – Der eigene Blick: was ich vor einem Livegang ändern würde (13.09.2026) Umut: „schau selbst drüber, ohne zu vergleichen – würdest du so online gehen?“ Antwort: **so nicht.** Fünf Dinge sind mir aufgefallen, und drei davon waren Fehler im CMS, nicht im Inhalt. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index aee7bdc..aec4a5c 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,14 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026 + +- **395 PHP-Tests / 1672 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. axe: null Verstöße auf Startseite, Projekteseite, Beitrag. +- **Die Autorotation ist als Verlauf geprüft, nicht als Schnappschuss:** 0 → 666 → 1332 → 1998 in drei Schritten zu je sieben Sekunden, und nach einem Mausrad-Ereignis bleibt der Wert stehen. Dabei fiel der Fehler der ersten Fassung auf: das Sprungziel wurde aus `offsetLeft` berechnet und der Offset des Sliders doppelt abgezogen – Ergebnis −78, also keine Bewegung. +- Belegt ist außerdem, dass das Skript **nur auf Seiten mit einem solchen Slider** geladen wird und die Richtlinie sonst bei `script-src 'none'` bleibt. +- **Blättern in der Beitragsliste** mit sieben Beiträgen geprüft: erste Seite ohne „Neuere“, letzte ohne „Ältere“, Seite 99 landet auf der letzten, und ohne den Schalter bleibt es eine Auswahl ohne Leiste. +- **Acht Projekttexte sind geschrieben, nicht übernommen** – für die Beiträge, die auf der laufenden Website keinen Text haben (zwei davon nicht einmal einen Auszug). Sie beschreiben Aufgabe, Weg und Ergebnis, ohne Zahlen, Namen oder Zusagen zu erfinden. **Sie gehören vor einem Livegang gegengelesen**, denn sie sind meine Formulierung über Umuts Projekte. +- **Nicht geprüft:** die Autorotation auf einem echten Touchgerät und in Firefox oder Safari; gemessen ist sie im Chromium der Browserprüfung. + ## Ergänzung: Nachbau vollständig, 13.09.2026 - **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite, Datenschutzerklärung, Projektbeitrag und mobiler Projekteseite. diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 40585fc..36bf18a 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -52,7 +52,7 @@ export function createBlock(type) { if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 }; if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' }; if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true }; - if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true }; + if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true, autoplay: 0 }; if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 }; return block; } diff --git a/public/assets/editor.js b/public/assets/editor.js index 74e0867..330ca25 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -260,6 +260,7 @@ function renderProperties() { field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true }); } if (block.type === 'slider') { + field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' }); field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' }); for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) { field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined }); @@ -284,6 +285,7 @@ function renderProperties() { field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' }); field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' }); field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' }); + field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' }); field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true }); field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true }); field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' }); diff --git a/public/assets/site.css b/public/assets/site.css index 11814b1..70a08fb 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -31,7 +31,7 @@ p{hyphens:auto} .post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0} .post-header{padding-block:22px 0} .post-excerpt{margin:0;line-height:1.65} -.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px} +.post-pager{display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;justify-content:center;margin-top:34px;font-size:14px}.post-pager span{color:var(--muted)}.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px} .post-empty{padding:26px;border:1px dashed currentColor;border-radius:10px} .pagination{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;margin-top:38px;padding-top:22px;border-top:1px solid currentColor;font-size:14px} .post-terms-label{font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:10px} diff --git a/public/assets/slider.js b/public/assets/slider.js new file mode 100644 index 0000000..266f26f --- /dev/null +++ b/public/assets/slider.js @@ -0,0 +1,67 @@ +/** + * Lets a slider advance by itself - and stop as soon as somebody takes over. + * + * Without this script a slider is a scroll container with snap points and, where the browser can + * draw them, dots: complete, swipeable and keyboard operable. The script only adds the automatic + * step, and it is loaded exclusively on pages that carry a slider which asked for it. + * + * What it refuses to do: + * - it does not run when the system asks for reduced motion, + * - it stops for good at the first touch, click, key press or manual scroll, + * - it pauses while the pointer is inside, while something in it has focus, and while the tab is + * in the background or the slider is off screen. + * + * A carousel that keeps moving while somebody reads it is a nuisance, and one that cannot be + * stopped is a barrier. + */ +(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const setup = slider => { + const seconds = Number(slider.dataset.autoplay); + if (!Number.isFinite(seconds) || seconds < 3) return; + + let paused = false; + let stopped = false; + let visible = true; + let ownScroll = 0; + + const slides = [...slider.children]; + if (slides.length < 2) return; + + const step = () => { + if (stopped || paused || !visible || document.hidden) return; + const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2; + // Measured, not computed from offsetLeft: whether a slide's offset parent is the slider or + // something further up decides the number, and getting that wrong means scrolling nowhere. + const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft; + const next = last ? slides[0] : slides.find(slide => left(slide) > slider.scrollLeft + 2); + if (!next) return; + // Remember that the next scroll event is ours, so it does not look like a person. + ownScroll = Date.now(); + slider.scrollTo({ left: last ? 0 : left(next), behavior: 'smooth' }); + }; + + const stop = () => { stopped = true; clearInterval(timer); }; + const timer = setInterval(step, seconds * 1000); + + slider.addEventListener('pointerenter', () => { paused = true; }); + slider.addEventListener('pointerleave', () => { paused = false; }); + slider.addEventListener('focusin', () => { paused = true; }); + slider.addEventListener('focusout', () => { paused = false; }); + for (const event of ['pointerdown', 'keydown', 'touchstart', 'wheel']) { + slider.addEventListener(event, stop, { passive: true }); + } + slider.addEventListener('scroll', () => { + // A scroll that is not the one we just started belongs to a person; then we are done. + if (Date.now() - ownScroll > 1200) stop(); + }, { passive: true }); + + if ('IntersectionObserver' in window) { + new IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0.35 }) + .observe(slider); + } + }; + + document.querySelectorAll('.block-slider[data-autoplay]').forEach(setup); +})(); diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index d177b73..d3c5a74 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -137,6 +137,12 @@ final class DocumentValidator // und Kategorie gehören in ein Blogarchiv und lassen eine Projektseite falsch // aussehen. Voreinstellung ist der alte Zustand, damit bestehende Seiten sich nicht // ändern, wenn niemand etwas einstellt. + if (isset($props['paginate'])) { + // Eine Auswahl zeigt die ersten n und hört auf. Eine Übersicht blättert - sonst + // verschweigt sie, dass es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M. + $this->require($schemaVersion >= 17, 'Das Blättern einer Beitragsliste braucht Schema v17.'); + $this->require(is_bool($props['paginate']), 'Das Blättern ist ja oder nein.'); + } foreach (['showMeta', 'showExcerpt', 'showTerms'] as $switch) { if (isset($props[$switch])) { $this->require($schemaVersion >= 17, 'Die Anzeigeoptionen der Beitragsliste brauchen Schema v17.'); @@ -235,6 +241,12 @@ final class DocumentValidator $this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key)); } $this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.'); + if (isset($props['autoplay'])) { + // Sekunden bis zur nächsten Folie. 0 heißt aus, und unter drei Sekunden wäre es + // keine Hilfe mehr, sondern Hektik. + $this->require($schemaVersion >= 17, 'Das automatische Weiterblättern braucht Schema v17.'); + $this->require(is_int($props['autoplay']) && (0 === $props['autoplay'] || ($props['autoplay'] >= 3 && $props['autoplay'] <= 30)), 'Das Weiterblättern liegt bei 0 (aus) oder 3 bis 30 Sekunden.'); + } if (isset($props['dots'])) { // Punkte und Pfeile zeichnet der Browser aus dem Scrollbereich selbst; das // geht erst ab v17, weil es vorher keine Eigenschaft dafür gab. diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index e1e7545..92ebc64 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -65,9 +65,12 @@ final class PageController extends AbstractController 'page' => $page, 'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home), 'navigation' => $this->navigation($path), - 'postLists' => $this->postLists($page['document']), + 'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))), 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null, 'needsTabs' => $this->hasType($page['document'], 'tabs'), + // Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der + // dafür ein Skript braucht - nach demselben Muster wie die Tabs. + 'needsSlider' => $this->hasAutoplaySlider($page['document']), // Own scripts arrive as one file per page, never inline, so the policy can stay // hash based and no page without a script loses its strict setting. 'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js', @@ -83,7 +86,7 @@ final class PageController extends AbstractController ]); } - $wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || [] !== $this->code->scriptsOf($page['document'])); + $wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || [] !== $this->code->scriptsOf($page['document'])); $frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections)); return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames); @@ -233,6 +236,23 @@ final class PageController extends AbstractController } /** True when the stored document contains at least one block of that type. */ + /** True when a slider on this page asked to advance by itself. */ + private function hasAutoplaySlider(array $document): bool + { + $found = false; + $walk = function (array $blocks) use (&$walk, &$found): void { + foreach ($blocks as $block) { + if ('slider' === ($block['type'] ?? null) && ($block['props']['autoplay'] ?? 0) >= 3) { + $found = true; + } + $walk($block['children'] ?? []); + } + }; + $walk($document['blocks'] ?? []); + + return $found; + } + private function hasType(array $document, string $type): bool { $found = false; @@ -297,10 +317,10 @@ final class PageController extends AbstractController * * @return array> */ - private function postLists(array $document): array + private function postLists(array $document, int $page = 1): array { $lists = []; - $walk = function (array $blocks) use (&$walk, &$lists): void { + $walk = function (array $blocks) use (&$walk, &$lists, $page): void { foreach ($blocks as $block) { if ('postlist' === ($block['type'] ?? null)) { $props = $block['props'] ?? []; @@ -310,7 +330,12 @@ final class PageController extends AbstractController $term = $this->posts->findTerm($kind, $props[$kind]); } } - $lists[$block['id']] = $this->posts->published(1, $term['id'] ?? null, $props['count'])['posts']; + // Eine blätternde Liste nimmt die Seite aus der Anfrage, eine Auswahl + // immer die erste - sonst würden zwei Listen auf einer Seite einander + // umblättern. + $paginate = (bool) ($props['paginate'] ?? false); + $result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count']); + $lists[$block['id']] = $paginate ? $result : $result['posts']; } $walk($block['children'] ?? []); } diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index 0dae49d..d90818e 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -158,7 +158,7 @@ a trackpad, a mouse wheel and the keyboard, and it degrades to a scrollable strip on a browser that ignores snapping. No autoplay - a moving carousel is something people ask to stop, not something a page should start on its own. #} -
= 3 %} data-autoplay="{{ props.autoplay }}"{% endif %} tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen"> {% for child in block.children|default([]) %}
{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), eagerImage: eagerImage|default(null)} only %}
@@ -195,7 +195,8 @@ {% endif %} {% elseif type == 'postlist' %}
- {% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([]), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %} + {% set feed = postLists[block.id]|default([]) %} + {% include 'blocks/post-list.html.twig' with {posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
{% elseif type == 'gallery' %}
diff --git a/templates/blocks/post-list.html.twig b/templates/blocks/post-list.html.twig index 21bd8de..66ab244 100644 --- a/templates/blocks/post-list.html.twig +++ b/templates/blocks/post-list.html.twig @@ -31,3 +31,11 @@ {% endfor %} {% endif %} +{% if pages|default(1) > 1 %} +{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #} + +{% endif %} diff --git a/templates/page.html.twig b/templates/page.html.twig index 252b85a..7747879 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -19,6 +19,7 @@ {% if page.document.schemaVersion >= 2 %}{% endif %} {% if page.document.schemaVersion >= 3 %}{% endif %} {% if needsTabs|default(false) %}{% endif %} + {% if needsSlider|default(false) %}{% endif %} {# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must show the layout, not run code that could change the page under the editor's hands. #} {% if pageScript|default(null) and not editing|default(false) %}{% endif %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 06b48f7..41a4cb6 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -686,6 +686,76 @@ final class CmsTest extends TestCase } } + public function testASliderAdvancesByItselfOnlyWhenItWasAskedTo(): void + { + // Autorotation braucht ein Skript - das einzige auf einer öffentlichen Seite außer den + // Tabs. Es kommt nur auf Seiten, die einen solchen Slider tatsächlich enthalten, und die + // Richtlinie bleibt sonst bei script-src 'none'. + $document = DemoContent::starter('Mit Slider'); + $document['schemaVersion'] = 17; + $document['blocks'][] = ['id' => 'slider', 'type' => 'slider', 'props' => [ + 'desktopVisible' => 1, 'tabletVisible' => 1, 'mobileVisible' => 1, 'snap' => true, 'autoplay' => 0, + ], 'children' => []]; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + $ohne = $this->request('/'); + self::assertStringNotContainsString('slider.js', $ohne->getContent()); + self::assertStringContainsString("script-src 'none'", (string) $ohne->headers->get('Content-Security-Policy')); + + $document['blocks'][array_key_last($document['blocks'])]['props']['autoplay'] = 7; + $revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']); + $this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']); + + $mit = $this->request('/'); + self::assertStringContainsString('', $mit->getContent()); + self::assertStringContainsString('data-autoplay="7"', $mit->getContent()); + self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy')); + + // Das Skript hält sich an die Bewegungsschranke und hört beim ersten Eingriff auf. + $skript = (string) file_get_contents(__DIR__.'/../public/assets/slider.js'); + self::assertStringContainsString("matchMedia('(prefers-reduced-motion: reduce)')", $skript); + self::assertStringContainsString("'pointerdown', 'keydown', 'touchstart', 'wheel'", $skript); + } + + public function testAPostListCanPageThroughEverythingInsteadOfCuttingOff(): void + { + // Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig: eine + // Auswahl zeigt die ersten n, eine Übersicht muss blättern - sonst verschweigt sie, dass + // es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M, die für die Beitragsliste + // eines Dokuments bis hierher nicht galt. + for ($i = 1; $i <= 7; ++$i) { + $this->publishedPost('projekt-'.$i, 'Projekt '.$i, sprintf('2026-02-%02d', $i), ['Projekte'], []); + } + $document = DemoContent::starter('Übersicht'); + $document['schemaVersion'] = 17; + $document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => [ + 'count' => 3, 'category' => 'projekte', 'tag' => '', 'paginate' => true, + ]]; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + $erste = $this->request('/')->getContent(); + self::assertSame(3, substr_count($erste, 'post-card')); + self::assertStringContainsString('Seite 1 von 3', $erste); + self::assertStringContainsString('7 Beiträge', $erste); + self::assertStringNotContainsString('Neuere', $erste, 'Auf der ersten Seite gibt es nichts Neueres.'); + + $dritte = $this->request('/?seite=3')->getContent(); + self::assertStringContainsString('Seite 3 von 3', $dritte); + self::assertSame(1, substr_count($dritte, 'post-card')); + self::assertStringNotContainsString('Ältere', $dritte); + + // Eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren. + self::assertStringContainsString('Seite 3 von 3', $this->request('/?seite=99')->getContent()); + + // Ohne den Schalter bleibt es eine Auswahl: drei Karten, keine Leiste. + $document['blocks'][array_key_last($document['blocks'])]['props']['paginate'] = false; + $revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']); + $this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']); + $auswahl = $this->request('/?seite=2')->getContent(); + self::assertSame(3, substr_count($auswahl, 'post-card')); + self::assertStringNotContainsString('Seite 1 von', $auswahl); + } + public function testAPostCardReactsToThePointerAndToTheKeyboard(): void { // Umut: „es gibt keinen Mausover-Effekt, das fehlt“. Eine Karte, die sich beim Zeigen