Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c1c86b864 | ||
|
|
c82a37643b | ||
|
|
cff468167b | ||
|
|
f182f9e125 | ||
|
|
37c2942d1d | ||
|
|
eea6747d9e |
No files matched your search
@@ -4,6 +4,31 @@ Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7.
|
||||
|
||||
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Formularbutton und Bewegungseffekte (ab 1.10.1)
|
||||
|
||||
„Absenden“ übernimmt den Website-Standardbutton aus Design/Typografie: Form, Darstellung, Größe, Schrift und normale/Hover-Farben. Bestehende Formulare aktualisieren sich ohne erneutes Speichern; der Button hat seine natürliche Breite statt automatisch die gesamte Formularbreite. Formularfelder, Versand und Validierung bleiben unverändert.
|
||||
|
||||
Einblendungen starten nach dem Wechsel von reduzierter zu erlaubter Bewegung wieder ohne Neuladen. Bereits gezeigte Elemente werden nicht erneut animiert. Im Editor zeigt eine Meldung, wenn der Browser Bewegung reduziert. Eine weiterhin aktive Browservorgabe wird beachtet; dieses Update überschreibt keine Besucherpräferenzen.
|
||||
|
||||
## Sichtbarkeit und Hamburger-Menüs (ab 1.10.0)
|
||||
|
||||
Element auswählen → **Sichtbarkeit**: auf allen Geräten, nur Desktop/Tablet/Mobil, auf einem Gerät ausblenden oder überall ausblenden. Die Auswahl steht in Inhalt, Design und Erweitert. Ausgeblendete Elemente bleiben links in der Struktur auswählbar; dort steht auch ihre Sichtbarkeit. „Auf allen Geräten“ setzt die Gerätebeschränkung zurück. Eigene Layoutwerte bleiben erhalten. Ausgeblendete Elternbereiche verbergen auch ihre Inhalte.
|
||||
|
||||
Waagerechte Navigation wird automatisch auf Tablet und Mobil zum Hamburger, auch auf großen iPads mit Touchbedienung ohne Maus. Unter **Inhalt → Automatisch als Hamburger** lässt sich stattdessen „Nur Mobil“ oder „Nie“ wählen. Vertikale Menüs bleiben vertikal und die Menüart „Burger“ bleibt immer aufklappbar. Bereits getrennt angelegte Desktop- und Mobilmenüs lassen sich über **Sichtbarkeit** passend zuordnen. Das Menü funktioniert ohne JavaScript und lässt sich mit Enter oder Leertaste öffnen und schließen.
|
||||
|
||||
Sichtbarkeitsgrenzen: Desktop ab 1025 px, Tablet 768–1024 px, Mobil bis 767 px. Undo/Redo, Speichern und Veröffentlichung gelten wie gewohnt. Neue Optionen verwenden Schema 22; Rückweg siehe ADR 088.
|
||||
|
||||
Gezielt prüfen (isolierte Kopie der lokalen Kant-IT-Daten; PHP mit SQLite3-Erweiterung):
|
||||
|
||||
```powershell
|
||||
$env:PHP_BINARY = 'C:\php84\php.exe'
|
||||
npm.cmd run test:responsive-navigation
|
||||
php vendor/bin/phpunit tests/ResponsiveVisibilityTest.php
|
||||
npm.cmd test
|
||||
```
|
||||
|
||||
Bericht und Screenshots: `reports/responsive-navigation/`. Produktionsbuild: `composer build`.
|
||||
|
||||
## Buttons, Sprungziele und Menüs (ab 1.9.0)
|
||||
|
||||
Unter Design/Typografie konfiguriert „Standardbutton“ Form, Darstellung, Größe, Schrift und Farben für die Website. Acht Vorlagen zeigen das Ergebnis direkt, einschließlich Hover-Vorschau. Einzelne Buttons können im Editor davon abweichen, auch je Gerät: eckig, abgerundet, Pille, Rahmen, dezent oder Textlink. Eigene freie Designwerte können diese Vorgaben übersteuern.
|
||||
@@ -40,6 +65,8 @@ Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website
|
||||
|
||||
## Anmelden und bearbeiten
|
||||
|
||||
**Scroll-Einblendungen prüfen:** Block auswählen → Design → Bewegung und Effekte → Einblendung beim Erscheinen → „Von links“ oder „Von rechts“. Anschließend speichern und veröffentlichen. Im Editor startet „Bewegung testen“ die Vorschauwiedergabe. Öffentliche Seiten animieren beim ersten Erscheinen; die Systemoption „Bewegung reduzieren“ unterdrückt Animationen. `node scripts/motion-check.mjs` prüft Scroll-Auslösung, beide Richtungen, Bewegungsreduktion und Ausgabe ohne JavaScript mit einer isolierten Kant-IT-Kopie. PHP mit SQLite3-Unterstützung über `PHP_BINARY` wählen, etwa in PowerShell `$env:PHP_BINARY='C:\php84\php.exe'`. Ergebnisse stehen unter `reports/motion/`.
|
||||
|
||||
**Bildausrichtung (ab 1.8.4):** Bild auswählen → Design → Layout → **Eigene Ausrichtung quer: Mitte**. Zentriert den Bildblock und auch kleinere Bilder innerhalb des Rahmens. Funktioniert mit Prozentbreite, maximaler Breite und Bildlinks; Anfang/Ende und Gerätewerte werden ebenfalls berücksichtigt. In nebeneinander angeordneten Flex-Gruppen bleibt die Querachse vertikal. Prüfen: `npm run test:image-alignment` auf einer isolierten Kant-IT-Kopie; Screenshots unter `reports/image-alignment/`.
|
||||
|
||||
**Akkordeon-Inhalte (ab 1.8.3):** Einträge bleiben im Editor geöffnet. Im Eintrag auf **Inhalt hinzufügen** klicken und links Text, Bild oder ein anderes Element auswählen; alternativ direkt in die sichtbare Inhaltsfläche ziehen. **Eintrag hinzufügen** steht unter dem Akkordeon und in dessen Inhaltsoptionen. Die Eintragsübersicht rechts und die Struktur links zeigen die Titel. **Beim Laden geöffnet** bestimmt ausschließlich den Zustand der veröffentlichten Seite. Prüfen: `npm run test:accordion`, Screenshots unter `reports/accordion/`.
|
||||
@@ -68,7 +95,7 @@ Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Str
|
||||
|
||||
Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern und den Serverstand bewusst neu laden. Auch Netzwerk-/Sitzungsfehler lassen den lokalen Stand erhalten. Die Vorschau behält bei ungültigen Eingaben die letzte gültige Ausgabe.
|
||||
|
||||
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 650 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
|
||||
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 767 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
|
||||
|
||||
Die Vorschau rendert auf Desktop mit mindestens 1240 px Layoutbreite und wächst bei mehr Platz mit. Tablet/Mobil verwenden 768/390 px; bei weniger Platz wird die Vorschau passend skaliert. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||
|
||||
@@ -163,6 +190,10 @@ Vier Auslöser, in aufsteigender Verlässlichkeit:
|
||||
|
||||
## PHP-Webhosting
|
||||
|
||||
**Archiv-Updates prüfen:** `node scripts/archive-update-check.mjs <altes-release.zip> <neues-release.zip>` testet im Produktionsmodus den vollständigen HTTP-Updateablauf mit isolierter Kopie von `var/kantit.db`. PHP mit SQLite3 über `PHP_BINARY` wählen. Downloadtransport lokal, Originaldatenbank unverändert; Protokolle unter `var/archive-check-*`. Ein drittes Argument `500` prüft ausdrücklich den bekannten Altfehler samt Wiederaufnahme. Um den nächsten Updateaufruf des korrigierten 1.10.2-Pakets ohne Folgerelease zu testen, dasselbe Paket zweimal angeben und ausschließlich für diesen Test `UPDATE_SOURCE_VERSION=1.10.1` setzen; geändert wird nur die Versionsdatei der Wegwerfinstallation.
|
||||
|
||||
**500 direkt nach einem Archiv-Update aus 1.10.1 oder älter:** `/admin/system` direkt öffnen und angebotene Migrationen abschließen. Der alte Updater kann nach erfolgreichem Dateitausch seinen eigenen Cache löschen und beim Antworten abbrechen. Ab 1.10.2 behalten laufende Anfragen ihren Cache; neue Releases erhalten ein eigenes Cacheverzeichnis. Details und Grenzen stehen in ADR 090. Nicht erneut installieren oder die Datenbank ersetzen.
|
||||
|
||||
Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
|
||||
|
||||
- Gepflegte PHP-Version ab 8.2, PDO MySQL und die Composer-Plattformanforderungen.
|
||||
|
||||
@@ -2,6 +2,34 @@
|
||||
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 090 – Releasecache bleibt für laufende Updateanfragen erhalten (14.09.2026)
|
||||
|
||||
Der echte HTTP-Updatepfad von 1.10.0 nach 1.10.1 reproduziert Umuts 500 auf `/admin/system/archiv`: Die Dateien sind bereits erfolgreich ersetzt, dann entfernt `ArchiveUpdate` den gesamten `var/cache`. Symfony lädt beim Abschluss weitere Dienste aus dem kompilierten Container nach und findet deren PHP-Dateien nicht mehr. Eine frische Installation und isolierte Updater-Rückgabewerte decken diesen Fehler nicht ab.
|
||||
|
||||
Der Kernel verwendet deshalb `var/cache/<Umgebung>/release-<SHA256-Präfix von VERSION>`. Er merkt sich diesen Pfad pro Kernelinstanz, damit das Überschreiben von VERSION während der Anfrage ihren Cachepfad nicht ändert. Der Archiv-Updater löscht keinen Cache mehr; die folgende Anfrage startet mit dem eigenen Container- und Twigcache der neuen Version. Alte Cacheverzeichnisse bleiben absichtlich erhalten. Sie können bei gestoppter Anwendung entfernt werden, nicht während eines laufenden Updates. Keine Datenmigration; ein Downgrade verwendet wieder den zu seiner Fassung gehörenden Cache und kann ihn bei Bedarf über die Konsole erneuern.
|
||||
|
||||
**Übergang:** Ein bereits installierter Updater bis einschließlich 1.10.1 führt noch seinen alten Löschcode aus, auch wenn er 1.10.2 installiert. Dieser Updateaufruf kann einmalig weiterhin mit 500 enden. `/admin/system` direkt öffnen und die angebotenen Migrationen abschließen; die Marke in `var/update/offen.json` erhält den Fortschritt. Die Reparatur verhindert den Abbruch bei nachfolgenden Updates aus dem korrigierten Stand. Kein Erfolg der Produktionsmigrationen wird ohne Prüfung behauptet.
|
||||
|
||||
**Regression:** `scripts/archive-update-check.mjs` installiert zwei vollständige Releasearchive in einem eigenen Verzeichnis und prüft den echten HTTP-POST, Wartungszustand, Weiterleitung, erhaltene Sitzung, Systemseite, Migrationsabschluss, Sicherung und öffentliche Ausgabe. Nur der Downloadtransport wird auf das lokale Zielarchiv umgestellt. `UPDATE_SOURCE_VERSION` erlaubt eine ausdrücklich synthetische vorherige Versionsnummer auf dem korrigierten Paket, um dessen nächsten Updateaufruf vor einem weiteren Release zu prüfen. Die Original-Pilotdatenbank bleibt unverändert.
|
||||
|
||||
## ADR 089 – Formularaktionen nutzen den Website-Standardbutton (14.09.2026)
|
||||
|
||||
Der öffentliche Absenden-Button erhält dieselbe Klasse `cms-button` wie normale Buttonblöcke. Die bisherigen formularspezifischen Farb- und Hover-Regeln entfallen; die bereits vorhandenen Theme-Variablen bestimmen Form, Darstellung, Größe, Schrift und Farben. `justify-self:start` verhindert das implizite Strecken durch das Formular-Grid. Es werden keine Designwerte in Formulardefinitionen kopiert: spätere Änderungen am Website-Standard wirken auch auf vorhandene Formulare. Nativer Submit-Typ, Beschriftung, Fokus, Validierung und Versand bleiben erhalten. Keine Daten-/Schemamigration; ein Code-Rückweg stellt das frühere Sonderdesign wieder her.
|
||||
|
||||
## ADR 088 – Gerätesichtbarkeit und automatische Hamburger-Navigation (14.09.2026)
|
||||
|
||||
`layout.visibility` enthält optional eine feste Auswahl: `all`, `desktop`, `tablet`, `mobile`, `hide-desktop`, `hide-tablet`, `hide-mobile`, `hidden`. Fehlend bedeutet überall sichtbar. Die Bedienidee wurde anhand der lokalen Flatsome-Dateien `inc/builder/shortcodes/commons/visibility.php` und `template-parts/header/header-main.php` geprüft; kein Fremdcode oder Asset wurde übernommen. Die Sichtbarkeit steht in allen Eigenschaftsansichten direkt am Element und wird im Strukturbaum benannt. Ausgeblendete Elemente bleiben dort auswählbar. Änderungen verwenden dieselbe History, Entwurfsspeicherung und Veröffentlichung wie andere Blockwerte.
|
||||
|
||||
Vorschau und Öffentlichkeit verwenden dieselben CSS-Klassen. `display:none!important` entfernt ausgeblendete Elemente aus Layout, Tastaturreihenfolge und Accessibility Tree, ohne ihre eigenen Flex-/Grid-/Display-Werte umzuschreiben. Die Grenzen folgen dem bestehenden freien StyleCompiler: Mobil bis 767 px, Tablet 768–1024 px, Desktop ab 1025 px. Die Sichtbarkeit eines Elternbereichs gilt damit auch für seine Kinder. Wrapper von Logos, Tablinks und Sliderfolien erhalten dieselbe Auswahl; Tabs und Slider überspringen ausgeblendete Einträge bei der Bedienung. Sichtbarkeit entfernt Inhalte nicht aus dem HTML und ist keine Zugriffskontrolle.
|
||||
|
||||
Waagerechte Menüblöcke wechseln standardmäßig bis 1024 px sowie bei `hover:none` und `pointer:coarse` zum Hamburger. Das umfasst auch große iPads ohne Maus bei mehr als 1024 CSS-Pixeln; es gibt keine User-Agent-Erkennung. `props.collapse` erlaubt `tablet` (Standard), `mobile` (nur bis 767 px) und `never`. Bewusst vertikale Menüs bleiben vertikal, explizite Burger bleiben aufklappbar. Die Automatik wirkt auch auf bisherige waagerechte Menüs ohne neue Speicherung. Bereits separat angelegte Menüblöcke bleiben eigenständige Elemente und können mit der neuen Sichtbarkeit zugeordnet werden.
|
||||
|
||||
Die aufgeklappte Navigation verwendet native `details`/`summary` und benötigt kein öffentliches JavaScript. Der gemeinsame Menübaum wird für die breite und die aufklappbare Darstellung ausgegeben; CSS zeigt genau eine Variante, die andere fehlt auch in der Fokusreihenfolge. Das kostet etwas zusätzliches HTML, vermeidet aber eine skriptabhängige Navigation. Der Summary-Name bleibt „Menü“, der Browser vermittelt den Öffnungszustand. Im Editor wird der Öffnungszustand nur in der Vorschau umgeschaltet und nicht gespeichert.
|
||||
|
||||
**Migration:** Schema 22 wird beim ersten Setzen einer neuen Sichtbarkeits- oder Hamburger-Option verwendet. Bestehende Dokumente bleiben unverändert lesbar; keine SQL-Migration. Der Validator weist unbekannte Werte und neue Optionen in alten Schemata ab.
|
||||
|
||||
**Rückweg:** Vor einem Downgrade sichern. Schema-22-Dokumente exportieren, rekursiv `layout.visibility` und bei Menüs `props.collapse` entfernen, `schemaVersion` auf 21 setzen, mit dem alten Validator prüfen und importieren; alternativ die vorherige Sicherung wiederherstellen. Damit entfallen die neuen Gerätebeschränkungen und die automatische Umschaltung. Layout, Inhalt und stabile IDs bleiben erhalten.
|
||||
|
||||
## ADR 087 – Button-System, benannte Sprungziele und Menübedienung (14.09.2026)
|
||||
|
||||
Globale Button-Vorgaben liegen optional unter `theme.buttons`; Vorschau und öffentliche Ausgabe verwenden dasselbe kleine CSS-System mit Geräteklassen. Lokale Varianten stehen getrennt von Inhalt und freien Styles in `layout.button.<Gerät>`. Vorlagen ändern Form/Darstellung, erhalten Text, Link und Abstände und entfernen kollidierende freie Werte nur für die betroffene Einstellung am aktiven Gerät. Verbleibende eigene Styles können Varianten übersteuern. Die Bedienidee orientiert sich an der lokal bereitgestellten Flatsome-Button-Konfiguration (`inc/builder/shortcodes/button.php`); Implementierung und Assets wurden nicht übernommen.
|
||||
@@ -92,6 +120,8 @@ Die Mediathek filtert Titel und Alternativtext vor COUNT und LIMIT in der Datenb
|
||||
|
||||
## ADR 078 – Kontrollierbare Slider und Einblendungen beim Erscheinen (14.09.2026)
|
||||
|
||||
Ergänzung zur Laufzeit: Eine Änderung von reduzierter zu erlaubter Bewegung nimmt die Beobachtung noch nicht gezeigter Elemente wieder auf, auch wenn das Dokument schon mit reduzierter Bewegung gestartet ist. Die Aufräumfunktion entfernt denselben Präferenzlistener. Im Editor zeigt eine laufend aktualisierte Statusmeldung die tatsächlich gemeldete Browserpräferenz. Das CMS setzt diese weder aus einer Windows-Einstellung noch aus erfolgreichen Testprofilen voraus; es überschreibt sie nicht. Die Browsermeldung und ihre Ursache sind getrennt zu diagnostizieren.
|
||||
|
||||
Die bisherige Scroll-Zeitachse ersetzte unterschiedliche Effekte durch dieselbe Aufwärtsbewegung. `motion.js` beobachtet stattdessen sichtbare Blöcke und spielt die gewählte Richtung/Überblendung/Skalierung/Unschärfe einmal mit der Web Animations API. Verzögerungen bleiben erhalten. Ohne JavaScript sind alle Inhalte sichtbar; reduzierte Bewegung unterbindet die Wiedergabe und bricht laufende Animationen ab. Nur Dokumente mit einem Effekt laden das Modul, einschließlich Kopf/Fuß und verknüpften Sektionen. Inhalt und Dokumentschema bleiben unverändert.
|
||||
|
||||
Slider behalten Scroll-Snap als Grundlage. Ein kleines Modul ergänzt sichtbare Pfeile, Zähler, optionale Punkte sowie Pause/Wiedergabe bei Autoplay. Manuelle Bedienung stoppt auch einen bereits geplanten Timer. Automatik wartet bei Hover, außerhalb des Bildschirms oder in unsichtbaren Tabs; Bewegungsreduktion deaktiviert sie. Ohne Skript bleibt der Inhalt scrollbar. Im Editor startet der Benutzer diese Module ausdrücklich aus dem Elternfenster; die iframe-Sandbox bleibt `allow-same-origin` ohne `allow-scripts`. Neue Vorschauen räumen Observer, Timer und Ereignisse der alten Instanz auf.
|
||||
|
||||
+67
-1
@@ -1,12 +1,78 @@
|
||||
# Projektstatus
|
||||
|
||||
## 1.10.2 – Fehler 500 beim Abschluss des Archiv-Updateaufrufs (14.09.2026)
|
||||
|
||||
**Reproduziert und korrigiert:** Echter Produktionsmodus mit den vollständigen Paketen 1.10.0 → 1.10.1: Update-POST HTTP 500 nach erfolgreichem Dateitausch, fehlende PHP-Dienste im gerade gelöschten Containercache. Systemseite anschließend HTTP 200, Migrationsabschluss HTTP 302, öffentliche Seite danach HTTP 200. Ursache und Übergang: ADR 090. Der Kernel hält nun seinen Cachepfad pro Anfrage fest und verwendet für jede Releasefassung ein eigenes Verzeichnis. Der Archiv-Updater entfernt keine Dateien, die die laufende Anfrage noch benötigt.
|
||||
|
||||
**Bisher geprüft:** Gegenprobe mit korrigiertem Updater: POST 302, System 200, Abschluss 302, öffentlich 200. 16 gezielte PHP-Tests / 105 Assertions für Archive, Cachepfad und Systemverwaltung erfolgreich. Neuer wiederverwendbarer Paket-HTTP-Test reproduziert den alten Fehler sowie den erfolgreichen Wiederaufnahmeweg. Die Originaldatenbank bleibt unberührt.
|
||||
|
||||
**Release / nächster Schritt:** Paket 1.10.2 bauen und den Übergang aus 1.10.1 sowie einen weiteren Updateaufruf aus dem korrigierten Stand mit diesem Paket prüfen. Noch nicht veröffentlicht. Der bereits installierte alte Updater kann beim Einspielen dieser Reparatur einmalig weiter 500 ausgeben; anschließend `/admin/system` direkt öffnen und die angebotenen Migrationen abschließen. Das ist im Pakettest ausdrücklich zu prüfen. Kein Zugriff auf Produktionsprotokolle und keine Produktionsänderung durch diese Diagnose; Startseite und Anmeldung aktuell HTTP 200.
|
||||
|
||||
## 1.10.1 – Standardbutton im Formular und zuverlässiges Wiederaufnehmen von Einblendungen (14.09.2026)
|
||||
|
||||
**Umgesetzt:** „Absenden“ verwendet den konfigurierten Website-Standardbutton, einschließlich Form, Darstellung, Größe, Schrift sowie normalen und Hover-Farben. Harte Formularfarben entfernt und natürliche Buttonbreite hergestellt. Bestehende Formulare übernehmen Theme-Änderungen ohne Inhaltsmigration (ADR 089). Enthalten sind außerdem die unten dokumentierte Observer-Korrektur und die dynamische Bewegungsmeldung im Editor.
|
||||
|
||||
**Prüfungen:** Formularabweichung vor der Korrektur mit realen berechneten CSS-Werten reproduziert (`var/form-button-before.log`). 10 gezielte PHP-Tests / 76 Assertions für Formulare, Validierung, Versand, Spam-Schutz und Menüformular erfolgreich; eine alte Menüerwartung auf die seit 1.10.0 getrennten responsiven Darstellungen angepasst. 33 JavaScript-Tests, alle 49 Twig-Dateien, Produktionscontainer und PHP-Stilprüfung erfolgreich. Bewegungstest auf isolierter Pilotkopie bei 1600/390 px einschließlich Präferenzwechsel und Editorhinweis erneut erfolgreich. Kein neuer Lighthouse-Gesamtlauf oder Test des normalen Chrome-Benutzerprofils.
|
||||
|
||||
**Browser:** Drei Button-Prüfgruppen erfolgreich auf isolierter Kant-IT-Kopie. Globale Vererbung des Formularbuttons bei 1600/900/390 px ohne JavaScript mit berechneten CSS-Werten gegen einen gewöhnlichen Standardbutton verglichen; Hover, Tastaturfokus, natürliche Breite sowie spätere Umstellung von Rahmen/Pille auf gefüllt/abgerundet/groß mit eigenen Farben geprüft. Bestehende Button-Tests für Undo/Redo, Gerätevarianten und Veröffentlichung ebenfalls erfolgreich. Screenshots unter `reports/buttons/` visuell geprüft.
|
||||
|
||||
**Veröffentlicht:** Commit `cff4681` und Tag `v1.10.1` auf `origin`; [Release 1.10.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.10.1) als neuestes stabiles Release öffentlich geprüft. ZIP, SHA-256-Datei und Installer anonym heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3543 Archivdateien, 221 Paketquelldateien verglichen, ausschließlich Produktionsabhängigkeiten. Frischer Installer HTTP 200, `.env` vorhanden und keine `.env.local`; Produktionscontainer und Cache-Warmup im Release erfolgreich. ZIP-SHA-256: `24733774d14fe1c640681d01e62d2cef2b6a337b24b63af416965ba63cf4654c`.
|
||||
|
||||
**Nächster Schritt / Grenzen:** Über die Systemverwaltung aktualisieren und den Editor neu laden. Die Browservorgabe `prefers-reduced-motion` bleibt wirksam; ein dauerhaftes `true` wird durch dieses Release nicht überschrieben. Keine Kundeninhalte oder Systemeinstellungen verändert; auf `web.kant-it.de` hier kein Update installiert.
|
||||
|
||||
## Einblendungen nach Freigabe wieder aufnehmen – lokal korrigiert (14.09.2026)
|
||||
|
||||
**Neue Evidenz:** Umuts Screenshot zeigt eingeschaltete Windows-Animationseffekte. Auch die lokale Win32-Abfrage `SPI_GETCLIENTAREAANIMATION` ergibt `true`; kein Chrome-Prozess mit `--force-prefers-reduced-motion` gefunden. Die frühere Empfehlung, Windows-Animationen einzuschalten, ist damit nicht als Lösung belegt. Warum Umuts Chrome-Profil dennoch `prefers-reduced-motion: reduce` meldet, bleibt offen; eine Chrome-DevTools-Medienemulation ist eine mögliche, nicht verifizierte Ursache.
|
||||
|
||||
**CMS-Fehler reproduziert und behoben:** Der Observer in `motion.js` startete nach dem Wechsel von reduzierter zu erlaubter Bewegung nicht erneut. Der neue Browserfall scheiterte vor der Änderung (`var/motion-resume-before.log`). Jetzt werden noch nicht gezeigte Elemente wieder beobachtet; bereits gezeigte Elemente laufen weiterhin nur einmal, laufende Animationen werden bei Reduktion abgebrochen. Keine Änderung an der grundsätzlichen Beachtung der Browserpräferenz. Im Editor erscheint eine dynamische Statusmeldung, wenn der Browser Einblendungen, Hover-Bewegungen und Slider-Automatik unterdrückt.
|
||||
|
||||
**Geprüft:** Erweiterte `scripts/motion-check.mjs` auf isolierter Pilotkopie bei 1600/390 px erfolgreich: Start unter Reduktion und anschließende Freigabe ohne Reload, nachträgliches Sperren/Freigeben, keine Wiederholung bereits gezeigter Elemente, Hover aus/an, alle bisherigen Scroll-/Richtungsfälle, lesbare Ausgabe ohne JavaScript. Editorhinweis erscheint/verschwindet bei Präferenzänderung, Screenshot `reports/motion/editor-motion-status.png` visuell geprüft. 33 JavaScript-Tests, Twig-Lint für den Editor und Git-Diff-Prüfung erfolgreich; Originaldatenbank unverändert.
|
||||
|
||||
**Direkte Produktionsgegenprobe:** Mit installiertem Chrome in zwei getrennten Testkontexten auf `web.kant-it.de`: bei emuliertem `reduce` keine Einblendung und Hover-Transform `none`; bei `no-preference` Kontaktsektion mit `translateY(28px)` und App-Karte beim Hover um −4 px verschoben. Keine JavaScript-Seitenfehler; Bericht `reports/motion/production-proof.json`. Während der Prüfung war an `leistung-app` inzwischen `fx-entrance-none` gespeichert; keine Produktionsinhalte verändert. Dieser Vergleich zeigt die Wirkung der Präferenz, ersetzt jedoch keine Prüfung des normalen Benutzerprofils.
|
||||
|
||||
**Offen / nächster Schritt:** Die lokale Observer-Korrektur erklärt keinen dauerhaft wahren Browserwert und behebt bei weiter aktivem `reduce` nicht Umuts gesamten Produktionsbefund. Chrome-Rendering-Emulation prüfen; Windows-Einstellungen nicht erneut als Ursache behaupten. Lokale Änderungen noch nicht committed oder veröffentlicht, keine neue Datenmigration, kein neuer Lighthouse-Gesamtlauf.
|
||||
|
||||
## Produktionsdiagnose: Bewegung in Chrome unterdrückt (14.09.2026)
|
||||
|
||||
**Bestätigte Ursache:** Umut meldet für `https://web.kant-it.de/` in seinem Chrome `matchMedia('(prefers-reduced-motion: reduce)').matches === true`. Damit deaktivieren die vorhandenen CSS-/JavaScript-Regeln Einblendungen, Hover-Bewegungen und Slider-Automatik. Die öffentliche Karte `leistung-app` trägt tatsächlich `fx-hover-lift fx-entrance-left fx-delay-short`; die frühere Diagnose allein anhand der lokalen Pilotdaten war für Produktion unzureichend.
|
||||
|
||||
**Geprüft:** Produktions-HTML und Effektdateien HTTP 200; `motion.js?v=1.10.0` entspricht dem lokalen Modul, ausgeliefertes CSS enthält die Hover-Regel samt Bewegungsschranke. Im Codex-Browser ebenfalls reduzierte Bewegung und deaktivierte Slider-Automatik gemessen, keine JavaScript-Warnungen/-Fehler. Direkter Zugriff auf Umuts Chrome wurde von der Browsersteuerung wegen nicht sicher ermittelbarer URL gestoppt; den Wert aus seinem Browser hat Umut selbst bestätigt.
|
||||
|
||||
**Testverbesserung:** `scripts/motion-check.mjs` prüft zusätzlich den gespeicherten Hover-Effekt anhand der berechneten Verschiebung von −4 px und protokolliert die Bewegungsvorgabe separat ohne Medienemulation (`reducedMotion: null`). Optional installiertes Chrome über `MOTION_BROWSER_CHANNEL=chrome`; dieser Lauf auf einer isolierten Pilotkopie war bei 1600/390 px erfolgreich. Ein frisches Testprofil ersetzt weiterhin keine Messung im Benutzerprofil.
|
||||
|
||||
**Einordnung:** Der später bereitgestellte Screenshot und die Systemabfrage zeigen, dass Windows-Animationen bereits eingeschaltet sind (siehe neuerer Eintrag oben). Die Herkunft der Browsermeldung bleibt ungeklärt; keine erfolgreiche Wiedergabe im normalen Chrome-Profil bestätigt. Keine Systemoption, Produktionsdatei oder gespeicherter Inhalt geändert; kein neues Release.
|
||||
|
||||
## Prüfung der Scroll-Einblendungen mit Kant-IT-Daten (14.09.2026)
|
||||
|
||||
**Befund:** Die Startseite in `var/kantit.db` enthält in Entwurf und Veröffentlichung 14 Einblendungen, ausschließlich `rise` und `fade`. Keine seitlichen Effekte an den Startseitenelementen gespeichert. `rise` bewegt den Block 28 Pixel bei voller Deckkraft; überwiegend sind ganze Sektionen animiert. Der bestehende Renderer lädt `motion.js`, und der Observer löst beim Scrollen aus. Im Editor erfordert die Wiedergabe „Bewegung testen“; reduzierte Systembewegung unterdrückt sie.
|
||||
|
||||
**Geprüft:** `PHP_BINARY=C:\php84\php.exe node scripts/motion-check.mjs` (PowerShell: zuerst `$env:PHP_BINARY='C:\php84\php.exe'`) erfolgreich auf isolierter Pilotkopie im Produktionsmodus, Chromium mit Desktop 1600 und Mobil 390. Kontaktsektion wartet außerhalb des Bildschirms, startet nach Scrollen und läuft nur einmal. Beide seitlichen Optionen auf demselben Element temporär im Browser geprüft, inklusive tatsächlich berechneter Transformationsrichtung. Reduzierte Bewegung und sichtbarer Inhalt ohne JavaScript erfolgreich; keine JavaScript-Seitenfehler. Screenshots visuell geprüft, Bericht unter `reports/motion/result.json`. SHA-256 der Originaldatenbank vor/nach identisch.
|
||||
|
||||
**Ergebnis / nächster Schritt:** Kein Ausfall der Scroll-Laufzeit reproduziert; Anwendung und gespeicherte Inhalte unverändert. Für seitliche Bewegung die gewünschten Blöcke unter Design → Bewegung und Effekte → Einblendung beim Erscheinen auf „Von links“/„Von rechts“ stellen, speichern und veröffentlichen. Ob Umuts Browser aktuell Bewegung reduziert, wurde nicht geprüft. Keine neue Lighthouse- oder vollständige Barrierefreiheitsprüfung.
|
||||
|
||||
## 1.10.0 – Gerätesichtbarkeit und automatische Hamburger-Menüs (14.09.2026)
|
||||
|
||||
**Erledigt:** Eigene Sichtbarkeitsauswahl direkt am Element in Inhalt/Design/Erweitert: alle Geräte, nur Desktop/Tablet/Mobil, auf einem Gerät ausblenden oder überall ausblenden. Strukturbaum zeigt die Auswahl und erhält den Zugriff auf ausgeblendete Elemente. Waagerechte Menüs werden standardmäßig auf Tablet/Mobil und großen Touchgeräten ohne Maus zum Hamburger. Pro Menü sind „Nur Mobil“ und „Nie“ möglich. Vertikale und ausdrücklich gewählte Burger-Menüs behalten ihre Menüart. Native Menübedienung ohne öffentliches JavaScript; Öffnen auch in der Editorvorschau möglich. Tabs und Slider berücksichtigen ausgeblendete Einträge. Datenvertrag und Rückweg: ADR 088, Schema 22, keine SQL-Migration.
|
||||
|
||||
**Geprüft:** `PHP_BINARY=C:\php84\php.exe npm run test:responsive-navigation` (in PowerShell mit `$env:PHP_BINARY` setzen) erfolgreich mit vier Browser-Prüfgruppen auf isolierter Kant-IT-Kopie: Tastatur, Undo/Redo, Rücksetzen, Strukturzugriff, Vorschau, Speichern/Laden/Veröffentlichung; alle acht Sichtbarkeitsoptionen bei 1600/1025/1024/768/767/390 px; geerbte Elternsichtbarkeit, erhaltene Flex-/Gridwerte; alle Menüvarianten ohne JavaScript einschließlich Tastatur und Linknavigation; 1366-px-Touchprofil; Tabs/Slider und gezielte WCAG-Audits für Navigation, Tabs und Slidersteuerung auf Desktop/Mobil. Screenshots unter `reports/responsive-navigation/` visuell geprüft. Kein Testkonto oder Inhalt in den Originaldaten verändert. Der erste Start mit PHP 8.2 im PATH war wegen dessen fehlender SQLite3-Erweiterung nicht möglich; der dokumentierte PHP-8.4-Lauf ist erfolgreich.
|
||||
|
||||
**Weitere Prüfungen:** 33 JavaScript-Tests; 2 neue PHP-Tests mit 30 Assertions; 50 gezielte bestehende PHP-Tests zu Menüs, Dokument-Roundtrip und ungültigen Dokumenten mit 86 Assertions. PHP-Formatierung, alle 49 Twig-Dateien, Produktionscontainer und Cache-Warmup erfolgreich. Bestehende HTML-Erwartungen berücksichtigen jetzt beide responsiven Menüvarianten; der Browser prüft, dass jeweils nur eine zugänglich ist. Ein im Browser gefundener CSS-Fehler bei ausgeblendeten Slider-Punkten wurde korrigiert.
|
||||
|
||||
**Release-Prüfung 1.10.0:** 33 JavaScript-Tests und 139 gezielte PHP-Tests / 234 Assertions erfolgreich. Eine alte HTML-Erwartung im Test für gemeinsame Header/Footer an die vorhandene Menübeschriftung angepasst. Responsive-Navigationsprüfung mit vier Gruppen und Scroll-Prüfung bei 1600/390 px auf isolierten Pilotkopien erneut erfolgreich. Alle 49 Twig-Dateien, Produktionscontainer und PHP-Stilprüfung der drei betroffenen PHP-Dateien ohne Befund.
|
||||
|
||||
**Veröffentlicht:** Commit `37c2942` und Tag `v1.10.0` auf `origin`; [Release 1.10.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.10.0) als neuestes stabiles Release öffentlich geprüft. ZIP, SHA-256-Datei und Installer anonym heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3543 Archivdateien, 221 Paketquelldateien verglichen, ausschließlich Produktionsabhängigkeiten. Frische Installationsseite HTTP 200, `.env` vorhanden und keine `.env.local`; Produktionscontainer und Cache-Warmup im Release erfolgreich. ZIP-SHA-256: `beee5723eb27a2de58b4fc32c66a521a1469d7ded00046a25ddac51a34cc86ba`. Ergebnisse unter `var/release-verification-1.10.0.json`.
|
||||
|
||||
**Bekannte Grenzen / nächster Schritt:** Kein neuer Lighthouse-Gesamtlauf, keine neue Feldmessung, kein Test auf physischem iPad. Geräteklassen richten sich nach Viewportbreite; die Menüautomatik berücksichtigt zusätzlich Touchbedienung. Bereits separat angelegte Desktop-/Mobilmenüs können mit der neuen Sichtbarkeit zugeordnet werden. Nach dem Release über die Systemverwaltung aktualisieren und die gewünschten Sichtbarkeiten im echten Header festlegen. Kundenhostings werden hier nicht verändert.
|
||||
|
||||
## 1.9.0 – Button-Vorlagen, Sprungziele und bessere Menüs (14.09.2026)
|
||||
|
||||
**Erledigt:** Website-Standardbutton unter Typografie mit acht visuellen Vorlagen, normaler und Hover-Vorschau, Form, Darstellung, Größe, Schrift und Farben. Einzelne Buttons erhalten Gerätevarianten und Rücksetzen über den bestehenden Undo-/Speicherweg. Ein vorhandener Fehler beim Speichern leerer Palettenzeilen wurde behoben. Sprungziele wie `#kontakt` lassen sich an Sektionen, Zeilen und Spalten vergeben; interne IDs bleiben stabil und doppelte Ziele werden abgewiesen. Menüs haben sichtbare Drag-and-drop-Griffe, Tastaturbedienung, ordentliche SVG-Pfeile sowie optionale Icons vor/nach dem Text. Neben der Fassung erscheint ein Update-Link aus dem Ergebnis der bestehenden täglichen Prüfung.
|
||||
|
||||
**Gezielt geprüft:** Je drei Browser-Prüfgruppen für Buttons und Navigation auf isolierter Kant-IT-Kopie erfolgreich: Vorlagen und globale Speicherung, Gerätevarianten, Undo/Redo, Speichern/Laden, Veröffentlichung, Sprunglinks, Menü-Drag-and-drop/Tastatur, Icons und Update-Hinweis. Öffentliche Ausgabe ohne JavaScript geprüft; Screenshots unter `reports/buttons/` und `reports/navigation-ux/` visuell kontrolliert. Originaldaten unverändert. Twig-Lint und Produktionscontainer erfolgreich. Im gezielten PHP-Lauf zu Theme/Menüs bestanden 29 von 30 Tests unmittelbar; eine alte HTML-Erwartung wurde an die neue Icon-Beschriftung angepasst und der betroffene Test erfolgreich wiederholt (8 Assertions). PHP-Formatierung abgeschlossen. Auf Wunsch keine Gesamtprüfung und keine neue Lighthouse-Messung.
|
||||
|
||||
**Grenzen / nächster Schritt:** Release 1.9.0 bereitstellen. Update-Hinweis setzt den vorhandenen Aufgaben-Worker voraus und erscheint nach dessen Prüfung. Freie CSS-/Designwerte können Button-Vorlagen übersteuern. Schema 21 ist beim Rückweg zu berücksichtigen (ADR 087); keine Datenbankmigration. Kundenhostings werden hier nicht verändert.
|
||||
**Veröffentlicht:** Commit `3391fea` und Tag `v1.9.0` auf `origin`; [Release 1.9.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.9.0) ist das neueste stabile Release. ZIP, SHA-256-Datei und Installer öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3543 Archivdateien, 221 Paketquelldateien verglichen, ausschließlich Produktionsabhängigkeiten. Frische Installationsseite HTTP 200, `.env` vorhanden und keine `.env.local`. ZIP-SHA-256: `f48f135015f1ab2fb49a6f0a862d1ef414d20a8ca61a70d43e85c9b0e100bf62`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Über die Systemverwaltung aktualisieren und den Editor neu öffnen. Update-Hinweis setzt den vorhandenen Aufgaben-Worker voraus und erscheint nach dessen Prüfung. Freie CSS-/Designwerte können Button-Vorlagen übersteuern. Schema 21 ist beim Rückweg zu berücksichtigen (ADR 087); keine Datenbankmigration. Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.4 – Bildausrichtung sichtbar korrigiert (14.09.2026)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"build:buttons": "node scripts/build-buttons.mjs",
|
||||
"test:buttons": "node scripts/button-check.mjs",
|
||||
"test:navigation-ux": "node scripts/navigation-ux-check.mjs",
|
||||
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
|
||||
"build:design": "node scripts/build-design.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:editor": "node scripts/editor-check.mjs",
|
||||
|
||||
@@ -255,7 +255,21 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 21;
|
||||
export const SCHEMA_VERSION = 22;
|
||||
|
||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||
|
||||
export function setVisibility(document, id, value) {
|
||||
if (!Object.hasOwn(VISIBILITY, value)) throw new Error('Unbekannte Sichtbarkeit.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
if (value === 'all') {
|
||||
if (block.layout) delete block.layout.visibility;
|
||||
} else {
|
||||
upgradeDocument(document, 22);
|
||||
(block.layout ??= {}).visibility = value;
|
||||
}
|
||||
}
|
||||
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
|
||||
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
|
||||
@@ -60,6 +60,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.tree-item:hover{background:#f3f6ef}
|
||||
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600}
|
||||
.tree-icon{font-size:12px;min-width:15px}
|
||||
.tree-visibility{margin-left:auto;max-width:95px;font-size:10px;font-weight:400;line-height:1.3;color:#42584b;white-space:normal}
|
||||
.drop-target{outline:2px dashed #315b49}
|
||||
.block-actions{display:flex;flex-wrap:wrap;gap:5px;border-top:1px solid var(--line);padding-top:13px;margin-top:16px}
|
||||
.block-actions button{font-size:10px;padding:7px 9px;min-height:33px}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { preview, rowPreview } from './library-previews.js';
|
||||
import { decorateContainers } from './preview-containers.js';
|
||||
import { initMotion } from './motion.js';
|
||||
import { initSliders } from './slider.js';
|
||||
import { VISIBILITY, setVisibility } from './editor-model.js';
|
||||
|
||||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
|
||||
@@ -31,6 +32,10 @@ const collapsed = new Set();
|
||||
let renderedProperties = null;
|
||||
let revealAfterPreview = null;
|
||||
let previewPlayback = false, stopPlayback = () => {};
|
||||
const motionPreference = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const showMotionStatus = () => { $('#motion-status').hidden = !motionPreference.matches; };
|
||||
motionPreference.addEventListener('change', showMotionStatus);
|
||||
showMotionStatus();
|
||||
|
||||
function playPreview() {
|
||||
stopPlayback(); stopPlayback = () => {};
|
||||
@@ -125,6 +130,12 @@ function renderTree() {
|
||||
const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = blockIcon(block);
|
||||
const text = document.createElement('span'); text.textContent = displayText;
|
||||
button.append(icon, text); item.append(button);
|
||||
if (block.layout?.visibility && block.layout.visibility !== 'all') {
|
||||
const badge = document.createElement('small'); badge.className = 'tree-visibility';
|
||||
badge.textContent = VISIBILITY[block.layout.visibility];
|
||||
button.title = badge.textContent; button.append(badge);
|
||||
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText} – ${badge.textContent}`);
|
||||
}
|
||||
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
|
||||
list.append(item);
|
||||
}
|
||||
@@ -177,6 +188,19 @@ function renderProperties() {
|
||||
panel.append(note);
|
||||
return;
|
||||
}
|
||||
if (block) {
|
||||
const label = document.createElement('label'); label.htmlFor = 'block-visibility'; label.textContent = 'Sichtbarkeit';
|
||||
const select = document.createElement('select'); select.id = 'block-visibility';
|
||||
for (const [value, text] of Object.entries(VISIBILITY)) select.append(new Option(text, value));
|
||||
select.value = block.layout?.visibility ?? 'all';
|
||||
select.addEventListener('change', () => {
|
||||
history.change(doc => setVisibility(doc, block.id, select.value));
|
||||
renderTree(); controls(); schedulePreview();
|
||||
});
|
||||
const help = document.createElement('p'); help.className = 'field-help'; help.id = 'visibility-help';
|
||||
help.textContent = 'Desktop ab 1025 px · Tablet 768–1024 px · Mobil bis 767 px. Ausgeblendete Elemente bleiben links in der Struktur auswählbar. Ein ausgeblendeter Elternbereich blendet auch seinen Inhalt aus.';
|
||||
select.setAttribute('aria-describedby', help.id); panel.append(label, select, help);
|
||||
}
|
||||
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
|
||||
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||||
let imagePreview;
|
||||
@@ -217,6 +241,7 @@ function renderProperties() {
|
||||
if (key === 'caption') upgradeDocument(doc, 3);
|
||||
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
|
||||
if (key === 'newTab') upgradeDocument(doc, 20);
|
||||
if (key === 'collapse') upgradeDocument(doc, 22);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||||
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||
@@ -297,6 +322,7 @@ function renderProperties() {
|
||||
if (block.type === 'menu') {
|
||||
field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches' }, help: 'Welche Einträge gezeigt werden. Gepflegt werden sie unter Menüs.' });
|
||||
field('Menüart', 'layout', block.props.layout || 'horizontal', { choices: { horizontal: 'Waagerecht', vertical: 'Untereinander', burger: 'Aufklappbar (Burger)' }, help: 'Aufklappbar funktioniert ohne JavaScript. Untermenüs klappen in allen Arten per Klick auf, nicht beim Überfahren – das ist auf einem Touchgerät die einzige Art, die funktioniert.' });
|
||||
field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' });
|
||||
}
|
||||
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
|
||||
if (block.type === 'icon') {
|
||||
@@ -744,6 +770,10 @@ async function updatePreview() {
|
||||
}
|
||||
doc.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
const menuSummary = event.target.closest('.block-menu summary');
|
||||
if (menuSummary && !busy && !inlineEdit) {
|
||||
menuSummary.parentElement.open = !menuSummary.parentElement.open;
|
||||
}
|
||||
const insert = event.target.closest('[data-editor-target]');
|
||||
if (insert && !busy && history) {
|
||||
stopInlineEdit();
|
||||
|
||||
+13
-4
@@ -26,10 +26,19 @@ export function initMotion(doc = document) {
|
||||
const observer = new view.IntersectionObserver(entries => {
|
||||
for (const entry of entries) if (entry.isIntersecting) { show(entry.target); observer.unobserve(entry.target); }
|
||||
}, { threshold: 0, rootMargin: '0px 0px -24px 0px' });
|
||||
if (!preference.matches) nodes.forEach(node => observer.observe(node));
|
||||
const reduce = () => { if (preference.matches) { observer.disconnect(); animations.forEach(animation => animation.cancel()); } };
|
||||
preference.addEventListener('change', reduce);
|
||||
return () => { observer.disconnect(); animations.forEach(animation => animation.cancel()); preference.removeEventListener('change', reduce); };
|
||||
const syncPreference = () => {
|
||||
if (preference.matches) {
|
||||
observer.disconnect();
|
||||
animations.forEach(animation => animation.cancel());
|
||||
} else {
|
||||
// Reduction may have been active at startup or enabled during this visit.
|
||||
// Resume observing unseen content when movement is allowed again.
|
||||
nodes.forEach(node => { if (!seen.has(node)) observer.observe(node); });
|
||||
}
|
||||
};
|
||||
preference.addEventListener('change', syncPreference);
|
||||
syncPreference();
|
||||
return () => { observer.disconnect(); animations.forEach(animation => animation.cancel()); preference.removeEventListener('change', syncPreference); };
|
||||
}
|
||||
|
||||
if (!document.querySelector('#editor')) initMotion();
|
||||
+20
-1
@@ -56,7 +56,7 @@ p{hyphens:auto}
|
||||
.quote-rating{margin:0;letter-spacing:3px}
|
||||
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
|
||||
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
|
||||
.kant .block-form button[type=submit]{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-form button[type=submit]:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.kant .block-cta .button{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.kant .block-cta .button{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
|
||||
.block-cta p{margin:0;line-height:1.6}
|
||||
.block-contact{display:grid;gap:14px;font-style:normal}
|
||||
@@ -87,6 +87,7 @@ p{hyphens:auto}
|
||||
|
||||
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
|
||||
.block-form{display:grid;gap:20px;max-width:640px}
|
||||
.block-form>.cms-button{justify-self:start}
|
||||
.form-field{display:grid;gap:7px}
|
||||
.form-field label{font-weight:600;font-size:14px}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid currentColor;border-radius:8px;background:#fff;color:inherit;font:inherit;min-height:46px}
|
||||
@@ -332,6 +333,24 @@ body.consent-open{padding-bottom:260px}
|
||||
.menu-burger[open]>summary{border-radius:8px 8px 0 0}
|
||||
.menu-burger-body{padding:14px 0 4px}
|
||||
.menu-burger-body .menu-list{flex-direction:column;align-items:flex-start;gap:8px}
|
||||
/* Only one copy participates in layout, focus order and the accessibility tree. */
|
||||
.menu-collapse-tablet>.menu-burger,.menu-collapse-mobile>.menu-burger{display:none}
|
||||
@media (max-width:1024px),(hover:none) and (pointer:coarse){
|
||||
.menu-collapse-tablet>.menu-burger{display:block}
|
||||
.menu-collapse-tablet>.menu-list{display:none}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.menu-collapse-mobile>.menu-burger{display:block}
|
||||
.menu-collapse-mobile>.menu-list{display:none}
|
||||
}
|
||||
.menu-burger>summary{min-height:44px;min-width:44px}
|
||||
.menu-burger-body{overflow-wrap:anywhere}
|
||||
/* Visibility never overwrites the block's grid/flex/display settings. */
|
||||
.visibility-hidden{display:none!important}
|
||||
.slider-controls button[hidden]{display:none}
|
||||
@media (min-width:1025px){.visibility-tablet,.visibility-mobile,.visibility-hide-desktop{display:none!important}}
|
||||
@media (min-width:768px) and (max-width:1024px){.visibility-desktop,.visibility-mobile,.visibility-hide-tablet{display:none!important}}
|
||||
@media (max-width:767px){.visibility-desktop,.visibility-tablet,.visibility-hide-mobile{display:none!important}}
|
||||
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
|
||||
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
|
||||
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
|
||||
|
||||
+12
-5
@@ -11,6 +11,7 @@ export function initSliders(doc = document) {
|
||||
const seconds = Number(slider.dataset.autoplay || 0);
|
||||
let playing = seconds >= 3 && !preference.matches, hover = false, visible = false, timer;
|
||||
const controls = doc.createElement('div'); controls.className = 'slider-controls';
|
||||
for (const name of slider.classList) if (name.startsWith('visibility-')) controls.classList.add(name);
|
||||
controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', 'Slider bedienen');
|
||||
const button = (text, label, action) => {
|
||||
const node = doc.createElement('button'); node.type = 'button'; node.textContent = text;
|
||||
@@ -19,18 +20,22 @@ export function initSliders(doc = document) {
|
||||
controls.append(node); return node;
|
||||
};
|
||||
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
|
||||
const available = () => slides.map((slide, i) => slide.getClientRects().length ? i : -1).filter(i => i >= 0);
|
||||
const active = () => {
|
||||
let index = 0, distance = Infinity;
|
||||
slides.forEach((slide, i) => { const delta = Math.abs(left(slide) - slider.scrollLeft); if (delta < distance) { distance = delta; index = i; } });
|
||||
available().forEach(i => { const delta = Math.abs(left(slides[i]) - slider.scrollLeft); if (delta < distance) { distance = delta; index = i; } });
|
||||
return index;
|
||||
};
|
||||
const go = index => {
|
||||
if (!available().includes(index)) return;
|
||||
slider.scrollTo({ left: Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index]))), behavior: preference.matches ? 'instant' : 'smooth' });
|
||||
};
|
||||
const step = direction => {
|
||||
const visible = available(), position = visible.indexOf(active());
|
||||
if (!visible.length) return;
|
||||
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
|
||||
const first = slider.scrollLeft <= 2;
|
||||
go(direction > 0 ? (last ? 0 : Math.min(slides.length - 1, active() + 1)) : (first ? slides.length - 1 : Math.max(0, active() - 1)));
|
||||
go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)]));
|
||||
};
|
||||
const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
|
||||
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1)));
|
||||
@@ -45,10 +50,11 @@ export function initSliders(doc = document) {
|
||||
}
|
||||
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
|
||||
const update = () => {
|
||||
const fits = slider.scrollWidth <= slider.clientWidth + 2;
|
||||
const visible = available();
|
||||
const fits = visible.length < 2 || slider.scrollWidth <= slider.clientWidth + 2;
|
||||
controls.hidden = fits; previous.disabled = next.disabled = fits;
|
||||
const index = active(); count.textContent = `${index + 1} / ${slides.length}`;
|
||||
dots.forEach((dot, i) => dot.setAttribute('aria-pressed', String(i === index)));
|
||||
const index = active(); count.textContent = `${Math.max(0, visible.indexOf(index) + 1)} / ${visible.length}`;
|
||||
dots.forEach((dot, i) => { dot.hidden = !visible.includes(i); dot.setAttribute('aria-pressed', String(i === index)); });
|
||||
if (play) {
|
||||
play.textContent = playing ? 'Pause' : 'Wiedergabe';
|
||||
play.setAttribute('aria-label', playing ? 'Automatische Wiedergabe pausieren' : 'Automatische Wiedergabe starten');
|
||||
@@ -62,6 +68,7 @@ export function initSliders(doc = document) {
|
||||
const pause = () => { playing = false; update(); clearTimeout(timer); };
|
||||
slider.after(controls); slider.classList.add('slider-controlled'); update();
|
||||
slider.addEventListener('scroll', update, { passive: true, signal });
|
||||
view.addEventListener('resize', update, { passive: true, signal });
|
||||
slider.addEventListener('pointerenter', () => { hover = true; }, { signal });
|
||||
slider.addEventListener('pointerleave', () => { hover = false; schedule(); }, { signal });
|
||||
for (const event of ['pointerdown', 'wheel', 'touchstart', 'focusin']) slider.addEventListener(event, pause, { passive: true, signal });
|
||||
|
||||
+11
-3
@@ -25,14 +25,19 @@
|
||||
panels[index].querySelector('.tabs-heading')?.classList.add('visually-hidden');
|
||||
});
|
||||
|
||||
const available = () => links.map((link, i) => getComputedStyle(link.parentElement).display !== 'none' ? i : -1).filter(i => i >= 0);
|
||||
let selected = -1;
|
||||
const select = (index, moveFocus = true) => {
|
||||
const visible = available();
|
||||
if (!visible.includes(index)) index = visible[0] ?? -1;
|
||||
selected = index;
|
||||
links.forEach((link, i) => {
|
||||
const current = i === index;
|
||||
link.setAttribute('aria-selected', String(current));
|
||||
link.tabIndex = current ? 0 : -1;
|
||||
panels[i].hidden = !current;
|
||||
});
|
||||
if (moveFocus) links[index].focus();
|
||||
if (moveFocus && index >= 0) links[index].focus();
|
||||
};
|
||||
|
||||
list.addEventListener('click', event => {
|
||||
@@ -46,15 +51,18 @@
|
||||
list.addEventListener('keydown', event => {
|
||||
const index = links.indexOf(event.target.closest('a'));
|
||||
if (index === -1) return;
|
||||
const moves = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: links.length - 1 };
|
||||
const visible = available(), position = visible.indexOf(index);
|
||||
if (!visible.length) return;
|
||||
const moves = { ArrowRight: position + 1, ArrowLeft: position - 1, Home: 0, End: visible.length - 1 };
|
||||
if (!(event.key in moves)) return;
|
||||
event.preventDefault();
|
||||
select((moves[event.key] + links.length) % links.length);
|
||||
select(visible[(moves[event.key] + visible.length) % visible.length]);
|
||||
});
|
||||
|
||||
// A link from elsewhere may point straight at one panel.
|
||||
const deepLink = panels.findIndex(panel => panel.id === location.hash.slice(1));
|
||||
select(deepLink === -1 ? 0 : deepLink, false);
|
||||
window.addEventListener('resize', () => select(selected, false));
|
||||
group.classList.add('tabs-ready');
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Exercise the real HTTP update across two complete release archives in an isolated installation.
|
||||
// Usage: PHP_BINARY=php node scripts/archive-update-check.mjs old.zip new.zip [expected-status]
|
||||
// UPDATE_SOURCE_VERSION changes only the disposable source VERSION to test a future upgrade
|
||||
// from the fixed updater before a subsequent release exists.
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, readFile, writeFile, readdir, access } from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const [source, target, expected = '302'] = process.argv.slice(2);
|
||||
assert.ok(source && target, 'Supply the installed and target release ZIP paths.');
|
||||
const root = process.cwd().replaceAll('\\', '/');
|
||||
const run = `${root}/var/archive-check-${randomBytes(5).toString('hex')}`;
|
||||
await mkdir(run, { recursive: true });
|
||||
const originalHash = createHash('sha256').update(await readFile('var/kantit.db')).digest('hex');
|
||||
execFileSync(php, ['-r', '$z=new ZipArchive(); if(true!==$z->open($argv[1])||!$z->extractTo($argv[2]))exit(1); $z->close();', path.resolve(source), run], { windowsHide: true });
|
||||
const [directory] = (await readdir(run)).filter(name => /^kantsites-/.test(name));
|
||||
assert.ok(directory);
|
||||
const installation = `${run}/${directory}`;
|
||||
const targetVersion = execFileSync(php, ['-r', '$z=new ZipArchive(); $z->open($argv[1]); for($i=0;$i<$z->numFiles;$i++){if(preg_match("#^kantsites-[^/]+/VERSION$#",$z->getNameIndex($i)))echo trim($z->getFromIndex($i));}', path.resolve(target)], { encoding: 'utf8', windowsHide: true }).trim();
|
||||
assert.match(targetVersion, /^\d+\.\d+\.\d+$/);
|
||||
if (process.env.UPDATE_SOURCE_VERSION) await writeFile(`${installation}/VERSION`, `${process.env.UPDATE_SOURCE_VERSION}\n`);
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
await mkdir(`${installation}/var/media`, { recursive: true });
|
||||
// Only the download transport is replaced; the real archive, checksum, backup, file swap,
|
||||
// production kernel, session, redirects, migration and subsequent requests remain in use.
|
||||
await writeFile(`${run}/download.php`, `<?php
|
||||
class ArchiveCheckDownload extends App\\Ops\\Download {
|
||||
public function get(string $url): ?string {
|
||||
if(str_ends_with($url, '/releases/latest')) return json_encode(['tag_name'=>'v'.getenv('QA_TARGET_VERSION')]);
|
||||
if(str_ends_with($url, '.zip.sha256')) return hash_file('sha256', getenv('QA_TARGET_ZIP'));
|
||||
if(str_ends_with($url, '.zip')) return file_get_contents(getenv('QA_TARGET_ZIP'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
`);
|
||||
await writeFile(`${installation}/config/services.yaml`, (await readFile(`${installation}/config/services.yaml`, 'utf8')) + '\n App\\Ops\\Download:\n class: ArchiveCheckDownload\n');
|
||||
// Keep the test router outside the replaced bin/ directory (Windows locks the active script).
|
||||
await writeFile(`${run}/router.php`, `<?php
|
||||
$root = ${JSON.stringify(installation)};
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
if ($uri !== '/' && !str_contains($uri, '..') && is_file($root.'/public'.$uri)) return false;
|
||||
require_once $root.'/vendor/autoload.php';
|
||||
require_once ${JSON.stringify(`${run}/download.php`)};
|
||||
require $root.'/public/index.php';
|
||||
`);
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: `${installation}/var/media`, SITE_ORIGIN: origin, QA_TARGET_ZIP: path.resolve(target), QA_TARGET_VERSION: targetVersion };
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', `${run}/router.php`], { cwd: installation, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let log = '', browser;
|
||||
server.stdout.on('data', data => log += data); server.stderr.on('data', data => log += data);
|
||||
const result = { source: path.resolve(source), target: path.resolve(target), simulatedSourceVersion: process.env.UPDATE_SOURCE_VERSION ?? null };
|
||||
try {
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin + '/admin/login')).ok) { ready = true; break; } } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
assert.ok(ready, 'Isolated release must boot before testing an update.');
|
||||
browser = await chromium.launch(); const page = await browser.newPage();
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.locator('button[type=submit]').click();
|
||||
assert.equal((await page.goto(origin + '/admin/system')).status(), 200);
|
||||
const token = await page.locator('form[action="/admin/system/archiv"] [name="_csrf_token"]').first().inputValue();
|
||||
const response = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: token, target: targetVersion }, maxRedirects: 0 });
|
||||
result.updateStatus = response.status(); assert.equal(response.status(), Number(expected));
|
||||
if (response.status() === 302) assert.equal(response.headers().location, '/admin/system');
|
||||
assert.equal((await readFile(`${installation}/VERSION`, 'utf8')).trim(), targetVersion);
|
||||
const updateLog = await readFile(`${installation}/var/updates.log`, 'utf8');
|
||||
assert.ok(updateLog.includes('Dateien sind ersetzt')); assert.ok(!updateLog.includes('FEHLER'));
|
||||
assert.equal((await page.request.get(origin)).status(), 503, 'Maintenance stays active until migrations finish.');
|
||||
result.systemStatus = (await page.goto(origin + '/admin/system')).status(); assert.equal(result.systemStatus, 200);
|
||||
const finish = page.locator('form').filter({ has: page.locator('input[name=finish]') });
|
||||
assert.equal(await finish.count(), 1, 'The migration step remains accessible with the same session.');
|
||||
const csrf = await finish.locator('[name="_csrf_token"]').inputValue();
|
||||
const finished = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: csrf, finish: '1' }, maxRedirects: 0 });
|
||||
result.finishStatus = finished.status(); assert.equal(finished.status(), 302);
|
||||
result.publicStatus = (await page.request.get(origin)).status(); assert.equal(result.publicStatus, 200);
|
||||
assert.equal((await page.goto(origin + '/admin/system')).status(), 200);
|
||||
assert.equal(await page.locator('input[name=finish]').count(), 0);
|
||||
assert.equal((await readdir(`${installation}/var/backups`)).filter(name => name.startsWith('sicherung-')).length, 1);
|
||||
await access(`${installation}/.env`);
|
||||
assert.equal(createHash('sha256').update(await readFile('var/kantit.db')).digest('hex'), originalHash);
|
||||
if (Number(expected) === 302) assert.doesNotMatch(log, /\[critical\]|Failed to open stream|PHP Fatal error/);
|
||||
result.ok = true;
|
||||
} finally {
|
||||
await browser?.close(); server.kill();
|
||||
await writeFile(`${run}/server.log`, log); await writeFile(`${run}/result.json`, JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify({ ...result, report: run }, null, 2));
|
||||
}
|
||||
@@ -16,6 +16,10 @@ const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`,
|
||||
const report = 'reports/buttons'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
const buttonStyle = node => {
|
||||
const style = getComputedStyle(node);
|
||||
return Object.fromEntries(['backgroundColor', 'color', 'borderRadius', 'borderColor', 'borderWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'textTransform', 'lineHeight', 'textAlign'].map(key => [key, style[key]]));
|
||||
};
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
@@ -83,12 +87,49 @@ try {
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
|
||||
for (const [width, radius, transparent] of [[1600, '8px', false], [900, '0px', true], [390, '999px', false]]) {
|
||||
await publicPage.setViewportSize({ width, height: 900 }); await publicPage.goto(origin);
|
||||
const necessaryCookies = publicPage.getByRole('button', { name: 'Nur notwendige', exact: true });
|
||||
if (await necessaryCookies.count()) await necessaryCookies.click();
|
||||
const values = await publicPage.locator('#qa-button').evaluate(el => { const s=getComputedStyle(el);return { radius:s.borderRadius, bg:s.backgroundColor, font:s.fontSize }; });
|
||||
assert.equal(values.radius, radius); assert.equal(values.bg === 'rgba(0, 0, 0, 0)', transparent); assert.equal(values.font, '17px');
|
||||
assert.equal(await publicPage.locator('#qa-button-default').evaluate(el => getComputedStyle(el).fontSize), '18px');
|
||||
const submit = publicPage.locator('.block-form button[type=submit]').first();
|
||||
const standard = publicPage.locator('#qa-button-default');
|
||||
assert.deepEqual(await submit.evaluate(buttonStyle), await standard.evaluate(buttonStyle), 'Form submit uses global shape, appearance, size, typography and colors');
|
||||
assert.ok(await submit.evaluate(node => node.getBoundingClientRect().width < node.form.getBoundingClientRect().width), 'Submit keeps natural button width');
|
||||
await submit.hover(); await publicPage.waitForTimeout(350);
|
||||
const hoverStyle = await submit.evaluate(buttonStyle);
|
||||
await standard.hover(); await publicPage.waitForTimeout(350);
|
||||
assert.deepEqual(hoverStyle, await standard.evaluate(buttonStyle), 'Submit inherits the global hover colors');
|
||||
await submit.focus();
|
||||
assert.equal(await submit.evaluate(node => getComputedStyle(node).outlineStyle), 'solid', 'Submit has a keyboard focus indicator');
|
||||
await submit.screenshot({ path: `${report}/submit-${width}.png` });
|
||||
await publicPage.locator('#qa-buttons-section').screenshot({ path: `${report}/public-${width}.png` });
|
||||
}
|
||||
await plain.close(); checks.push('published global defaults and local responsive variants without JavaScript');
|
||||
await page.goto(origin + '/admin/theme');
|
||||
await page.locator('#buttons-shape').selectOption('rounded');
|
||||
await page.locator('#buttons-appearance').selectOption('solid');
|
||||
await page.locator('#buttons-size').selectOption('large');
|
||||
await page.locator('#buttons-fontSize').fill('20');
|
||||
for (const [key, hex] of [['backgroundColor', '#124b36'], ['color', '#ffffff'], ['hoverBackgroundColor', '#092c20'], ['hoverColor', '#fff4cc']]) {
|
||||
await page.locator(`#buttons-${key}`).click();
|
||||
await page.locator(`#buttons-${key}-hex`).fill(hex);
|
||||
await page.locator(`#buttons-${key}-hex`).press('Enter');
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
await page.getByRole('button', { name: 'Design speichern', exact: true }).click();
|
||||
await publicPage.goto(origin);
|
||||
const updatedSubmit = publicPage.locator('.block-form button[type=submit]').first();
|
||||
const updatedDefault = publicPage.locator('#qa-button-default');
|
||||
assert.deepEqual(await updatedSubmit.evaluate(buttonStyle), await updatedDefault.evaluate(buttonStyle), 'A later change to solid global defaults updates existing forms');
|
||||
assert.equal(await updatedSubmit.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(18, 75, 54)');
|
||||
await updatedSubmit.hover(); await publicPage.waitForTimeout(350);
|
||||
const updatedHover = await updatedSubmit.evaluate(buttonStyle);
|
||||
await updatedDefault.hover(); await publicPage.waitForTimeout(350);
|
||||
assert.deepEqual(updatedHover, await updatedDefault.evaluate(buttonStyle), 'Custom global hover colors update existing forms');
|
||||
await publicPage.setViewportSize({ width: 390, height: 1600 });
|
||||
await updatedSubmit.scrollIntoViewIfNeeded();
|
||||
await publicPage.locator('.block-form').first().screenshot({ path: `${report}/form-standard-button.png` });
|
||||
await plain.close(); checks.push('published global defaults, form submit including hover/focus/natural width, solid and outline theme changes, and local responsive variants without JavaScript');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Button checks passed:', checks.length, 'groups.');
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const hash = async () => createHash('sha256').update(await readFile('var/kantit.db')).digest('hex');
|
||||
const before = await hash();
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { env, windowsHide: true, stdio: 'ignore' });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/motion'; await mkdir(report, { recursive: true });
|
||||
const results = [], errors = [];
|
||||
let browser;
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch(process.env.MOTION_BROWSER_CHANNEL ? { channel: process.env.MOTION_BROWSER_CHANNEL } : {});
|
||||
const systemContext = await browser.newContext({ reducedMotion: null });
|
||||
const systemPage = await systemContext.newPage();
|
||||
await systemPage.goto(origin);
|
||||
const systemMotion = await systemPage.evaluate(() => ({ reduced: matchMedia('(prefers-reduced-motion: reduce)').matches, userAgent: navigator.userAgent }));
|
||||
console.log('Default browser motion preference:', JSON.stringify(systemMotion));
|
||||
await systemContext.close();
|
||||
for (const width of [1600, 390]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'no-preference' });
|
||||
await context.addInitScript(() => {
|
||||
window.motionCalls = [];
|
||||
const animate = Element.prototype.animate;
|
||||
Element.prototype.animate = function (frames, options) {
|
||||
const animation = animate.call(this, frames, options);
|
||||
window.motionCalls.push({ id: this.id, frames, options, scrollY, top: this.getBoundingClientRect().top });
|
||||
return animation;
|
||||
};
|
||||
});
|
||||
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin); await page.waitForTimeout(1200);
|
||||
const inventory = await page.locator('[class*="fx-entrance-"]').evaluateAll(nodes => nodes.map(node => ({ id: node.id, classes: node.className })));
|
||||
assert.ok(inventory.length > 0, 'Pilot has entrance effects');
|
||||
assert.equal(await page.locator('script[src*="motion.js"]').count(), 1);
|
||||
const hoverTarget = page.locator('#leistung-app');
|
||||
await hoverTarget.scrollIntoViewIfNeeded(); await page.waitForTimeout(800);
|
||||
await hoverTarget.hover(); await page.waitForTimeout(350);
|
||||
const hovered = await hoverTarget.evaluate(node => ({ transform: getComputedStyle(node).transform, matches: node.matches(':hover'), classes: node.className }));
|
||||
console.log('Pilot hover:', width, JSON.stringify(hovered));
|
||||
assert.equal(hovered.transform, 'matrix(1, 0, 0, 1, 0, -4)', 'Saved lift effect changes the rendered transform');
|
||||
const target = page.locator('#kontakt');
|
||||
assert.equal(await page.evaluate(() => motionCalls.some(call => call.id === 'kontakt')), false, 'Below-fold section must wait for scrolling');
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'));
|
||||
const calls = await page.evaluate(() => motionCalls);
|
||||
assert.equal(calls.find(call => call.id === 'kontakt').frames[0].transform, 'translateY(28px)');
|
||||
await page.waitForTimeout(800);
|
||||
await page.evaluate(() => scrollTo(0, 0)); await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(100);
|
||||
assert.equal(await page.evaluate(() => motionCalls.filter(call => call.id === 'kontakt').length), 1, 'Entrance runs once');
|
||||
await page.screenshot({ path: `${report}/pilot-${width}.png` });
|
||||
|
||||
// Exercise both existing choices on the real pilot element without editing saved content.
|
||||
for (const direction of ['left', 'right']) {
|
||||
await page.evaluate(async direction => {
|
||||
const node = document.getElementById('kontakt');
|
||||
node.classList.remove('fx-entrance-rise', 'fx-entrance-left', 'fx-entrance-right');
|
||||
node.classList.add(`fx-entrance-${direction}`);
|
||||
const { initMotion } = await import(document.querySelector('script[src*="motion.js"]').src);
|
||||
window.stopMotionCheck?.(); window.motionCalls = [];
|
||||
window.stopMotionCheck = initMotion();
|
||||
}, direction);
|
||||
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'));
|
||||
const actual = await target.evaluate(node => {
|
||||
const animation = node.getAnimations()[0]; animation.pause(); animation.currentTime = 150;
|
||||
return { transform: getComputedStyle(node).transform, frames: animation.effect.getKeyframes() };
|
||||
});
|
||||
assert.equal(actual.frames[0].transform, direction === 'left' ? 'translateX(-36px)' : 'translateX(36px)');
|
||||
const offset = Number(actual.transform.split(',')[4]);
|
||||
assert.ok(direction === 'left' ? offset < 0 : offset > 0, 'Computed movement follows selected direction');
|
||||
await page.screenshot({ path: `${report}/${direction}-${width}.png` });
|
||||
await page.evaluate(() => window.stopMotionCheck());
|
||||
}
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.reload(); await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(800);
|
||||
assert.equal(await page.evaluate(() => motionCalls.length), 0, 'Reduced motion suppresses animations');
|
||||
assert.equal(await target.evaluate(node => getComputedStyle(node).opacity), '1');
|
||||
await hoverTarget.hover(); await page.waitForTimeout(350);
|
||||
assert.equal(await hoverTarget.evaluate(node => getComputedStyle(node).transform), 'none', 'Reduced motion suppresses hover movement');
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'), null, { timeout: 3000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.waitForTimeout(100);
|
||||
assert.equal(await page.evaluate(() => motionCalls.filter(call => call.id === 'kontakt').length), 1, 'Preference changes do not replay an already seen element');
|
||||
await hoverTarget.hover(); await page.waitForTimeout(350);
|
||||
assert.equal(await hoverTarget.evaluate(node => getComputedStyle(node).transform), 'matrix(1, 0, 0, 1, 0, -4)', 'Hover resumes after allowing motion');
|
||||
// Also cover a page that starts normally, then suppresses motion before reaching its content.
|
||||
await page.reload(); await page.waitForTimeout(800);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(100);
|
||||
assert.equal(await page.evaluate(() => motionCalls.filter(call => call.id === 'kontakt').length), 0);
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'), null, { timeout: 3000 });
|
||||
results.push({ width, inventory, calls, checks: ['scroll trigger', 'once only', 'left/right computed movement', 'saved hover lift', 'reduced motion', 'resume without reloading after initial and subsequent reduced motion', 'hover resumes after preference change'] });
|
||||
await context.close();
|
||||
}
|
||||
const noJs = await browser.newContext({ javaScriptEnabled: false });
|
||||
const page = await noJs.newPage(); await page.goto(origin);
|
||||
assert.ok(await page.locator('main').innerText());
|
||||
assert.equal(await page.locator('#kontakt').evaluate(node => getComputedStyle(node).opacity), '1');
|
||||
await noJs.close();
|
||||
const editorContext = await browser.newContext({ reducedMotion: 'reduce', viewport: { width: 1600, height: 1000 } });
|
||||
const editor = await editorContext.newPage(); editor.on('pageerror', error => errors.push(error.message));
|
||||
await editor.goto(origin + '/admin/login');
|
||||
await editor.getByLabel('Benutzername').fill(fixture.username);
|
||||
await editor.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await editor.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await editor.waitForURL(origin + '/admin');
|
||||
await editor.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await editor.locator('#motion-status').waitFor({ state: 'visible' });
|
||||
await editor.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await editor.screenshot({ path: `${report}/editor-motion-status.png` });
|
||||
await editor.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await editor.locator('#motion-status').waitFor({ state: 'hidden' });
|
||||
await editor.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await editor.locator('#motion-status').waitFor({ state: 'visible' });
|
||||
await editorContext.close();
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(await hash(), before, 'Original database unchanged');
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ systemMotion, results, errors, editorMotionStatus: true, noJavaScript: true, originalUnchanged: true }, null, 2));
|
||||
console.log(JSON.stringify({ passed: true, devices: results.map(result => result.width), effects: results[0].inventory, report }));
|
||||
} finally {
|
||||
await browser?.close(); server.kill();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/responsive-navigation'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
data.document.schemaVersion = 22;
|
||||
const visibility = ['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'];
|
||||
const text = (id, title, visible = 'all') => ({ id, type: 'text', props: { text: `<p>${title}</p>` }, layout: { visibility: visible } });
|
||||
const nav = (id, layout = 'horizontal', collapse) => ({ id, type: 'menu', props: { slot: 'header', layout, ...(collapse ? { collapse } : {}) } });
|
||||
data.document.blocks.unshift({ id: 'qa-responsive', type: 'section', children: [
|
||||
{ id: 'qa-title', type: 'heading', props: { text: 'Gerätesichtbarkeit und Navigation', level: 2 } },
|
||||
nav('qa-menu'), nav('qa-mobile-menu', 'horizontal', 'mobile'), nav('qa-never-menu', 'horizontal', 'never'), nav('qa-vertical', 'vertical'), nav('qa-burger', 'burger'),
|
||||
...visibility.map(value => text(`qa-${value}`, value, value)),
|
||||
{ id: 'qa-parent', type: 'column', layout: { visibility: 'mobile' }, style: { desktop: { display: 'flex' }, mobile: { display: 'grid' } }, children: [text('qa-child', 'Vererbte Sichtbarkeit')] },
|
||||
{ id: 'qa-tabs', type: 'tabs', children: [
|
||||
{ id: 'qa-tab-desktop', type: 'tabitem', props: { title: 'Desktop-Tab' }, layout: { visibility: 'desktop' }, children: [text('qa-tab-text-d', 'Desktopinhalt')] },
|
||||
{ id: 'qa-tab-mobile', type: 'tabitem', props: { title: 'Mobil-Tab' }, layout: { visibility: 'mobile' }, children: [text('qa-tab-text-m', 'Mobilinhalt')] },
|
||||
{ id: 'qa-tab-all', type: 'tabitem', props: { title: 'Immer-Tab' }, children: [text('qa-tab-text-a', 'Gemeinsamer Inhalt')] },
|
||||
] },
|
||||
{ id: 'qa-slider', type: 'slider', props: { desktopVisible: 1, tabletVisible: 1, mobileVisible: 1, snap: true, dots: true, autoplay: 0 }, children: [text('qa-slide-d', 'Desktopfolie', 'desktop'), text('qa-slide-m', 'Mobilfolie', 'mobile'), text('qa-slide-a', 'Gemeinsame Folie')] },
|
||||
] });
|
||||
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
|
||||
assert.equal(created.status(), 200, await created.text());
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await page.locator('#structure [data-block-id="qa-all"]').click();
|
||||
await page.locator('#block-visibility').selectOption('mobile');
|
||||
const previewHidden = async hidden => page.waitForFunction(hidden => {
|
||||
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-all');
|
||||
return el && (el.getClientRects().length === 0) === hidden;
|
||||
}, hidden);
|
||||
await previewHidden(true);
|
||||
await page.locator('#undo').click(); await previewHidden(false);
|
||||
await page.locator('#redo').click(); await previewHidden(true);
|
||||
await page.locator('#structure [data-block-id="qa-all"]').click();
|
||||
await page.locator('#block-visibility').selectOption('all'); await previewHidden(false);
|
||||
await page.locator('#block-visibility').focus(); await page.locator('#block-visibility').press('ArrowDown'); await page.locator('#block-visibility').press('Tab');
|
||||
assert.equal(await page.locator('#block-visibility').inputValue(), 'desktop');
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#structure [data-block-id="qa-menu"]').click();
|
||||
assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet');
|
||||
await page.locator('#property-collapse').selectOption('never');
|
||||
await page.locator('#undo').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet');
|
||||
await page.locator('#redo').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'never');
|
||||
await page.locator('#property-collapse').selectOption('tablet');
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
const preview = page.frameLocator('#preview');
|
||||
await preview.locator('#qa-menu > .menu-burger > summary').click();
|
||||
assert.equal(await preview.locator('#qa-menu > .menu-burger').getAttribute('open'), '');
|
||||
await page.screenshot({ path: `${report}/editor-tablet.png`, fullPage: true });
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload();
|
||||
await page.locator('#structure [data-block-id="qa-menu"]').click();
|
||||
assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet');
|
||||
await page.locator('#structure [data-block-id="qa-mobile"]').click();
|
||||
assert.equal(await page.locator('#block-visibility').inputValue(), 'mobile');
|
||||
await page.locator('[data-panel="design"]').click(); assert.equal(await page.locator('#block-visibility').inputValue(), 'mobile');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const stored = (await (await context.request.get(api)).json()).document;
|
||||
assert.equal(stored.schemaVersion, 22);
|
||||
assert.deepEqual(stored.blocks[0].children.find(b => b.id === 'qa-parent').style, data.document.blocks[0].children.find(b => b.id === 'qa-parent').style);
|
||||
checks.push('Editor: visibility keyboard selection, hidden block selection, reset, undo/redo, hamburger preview, save/reload/publish and preserved layout');
|
||||
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1600, height: 1050 } });
|
||||
const publicPage = await plain.newPage(); await publicPage.goto(origin);
|
||||
const necessary = publicPage.getByRole('button', { name: 'Nur notwendige', exact: true });
|
||||
if (await necessary.isVisible()) await necessary.click();
|
||||
for (const width of [1600, 1025, 1024, 768, 767, 390]) {
|
||||
await publicPage.setViewportSize({ width, height: 1050 });
|
||||
const device = width > 1024 ? 'desktop' : width > 767 ? 'tablet' : 'mobile';
|
||||
for (const value of visibility) {
|
||||
const shown = value === 'all' || value === device || (value.startsWith('hide-') && value !== `hide-${device}`);
|
||||
assert.equal(await publicPage.locator(`#qa-${value}`).isVisible(), shown, `${width}: ${value}`);
|
||||
}
|
||||
assert.equal(await publicPage.locator('#qa-child').isVisible(), device === 'mobile');
|
||||
if (device === 'mobile') assert.equal(await publicPage.locator('#qa-parent').evaluate(el => getComputedStyle(el).display), 'grid');
|
||||
assert.equal(await publicPage.locator('#qa-menu > .menu-burger > summary').isVisible(), width <= 1024);
|
||||
assert.equal(await publicPage.locator('#qa-menu > .menu-list').isVisible(), width > 1024);
|
||||
assert.equal(await publicPage.locator('#qa-mobile-menu > .menu-burger > summary').isVisible(), width <= 767);
|
||||
assert.equal(await publicPage.locator('#qa-never-menu > .menu-list').isVisible(), true);
|
||||
assert.equal(await publicPage.locator('#qa-vertical > .menu-list').isVisible(), true);
|
||||
assert.equal(await publicPage.locator('#qa-burger > .menu-burger > summary').isVisible(), true);
|
||||
if ([1600, 1024, 390].includes(width)) { await publicPage.evaluate(() => window.scrollTo(0, 0)); await publicPage.screenshot({ path: `${report}/public-${width}.png`, fullPage: true }); }
|
||||
}
|
||||
const summary = publicPage.locator('#qa-menu > .menu-burger > summary');
|
||||
await summary.focus(); await publicPage.keyboard.press('Enter');
|
||||
assert.equal(await publicPage.locator('#qa-menu > .menu-burger').getAttribute('open'), '');
|
||||
await publicPage.keyboard.press('Tab');
|
||||
assert.ok(await publicPage.evaluate(() => document.activeElement.closest('#qa-menu > .menu-burger')));
|
||||
const branch = publicPage.locator('#qa-menu .menu-burger .menu-branch > summary').first();
|
||||
if (await branch.count()) { await branch.focus(); await publicPage.keyboard.press('Space'); assert.ok(await branch.evaluate(el => el.parentElement.open)); }
|
||||
await summary.focus(); await publicPage.keyboard.press('Space');
|
||||
assert.equal(await publicPage.locator('#qa-menu > .menu-burger').getAttribute('open'), null);
|
||||
await summary.press('Enter');
|
||||
const firstLink = publicPage.locator('#qa-menu .menu-burger a:visible').first();
|
||||
const href = await firstLink.getAttribute('href'); await firstLink.click(); assert.equal(publicPage.url(), new URL(href, origin).href);
|
||||
await plain.close();
|
||||
checks.push('Public without JavaScript: six viewport boundaries, every visibility preset, parent visibility, default and manual menu variants, keyboard toggle and real navigation');
|
||||
|
||||
const tablet = await browser.newContext({ javaScriptEnabled: false, hasTouch: true, isMobile: true, viewport: { width: 1366, height: 1024 } });
|
||||
const ipad = await tablet.newPage(); await ipad.goto(origin);
|
||||
assert.equal(await ipad.locator('#qa-menu > .menu-burger > summary').isVisible(), true);
|
||||
assert.equal(await ipad.locator('#qa-menu > .menu-list').isVisible(), false);
|
||||
await ipad.locator('#qa-menu > .menu-burger > summary').tap();
|
||||
assert.equal(await ipad.locator('#qa-menu > .menu-burger').getAttribute('open'), '');
|
||||
await ipad.locator('#qa-menu').screenshot({ path: `${report}/large-tablet.png` }); await tablet.close();
|
||||
checks.push('Large tablet: 1366 px touch viewport uses the hamburger and opens by touch without JavaScript');
|
||||
|
||||
const enhanced = await browser.newContext({ viewport: { width: 1600, height: 1050 }, bypassCSP: true });
|
||||
const dynamic = await enhanced.newPage(); await dynamic.goto(origin);
|
||||
await dynamic.locator('#qa-tabs.tabs-ready').waitFor();
|
||||
const audit = async () => {
|
||||
const result = await new AxeBuilder({ page: dynamic }).include('#qa-menu').include('#qa-tabs').include('#qa-slider + .slider-controls').withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(result.violations.map(item => ({ id: item.id, nodes: item.nodes.map(node => node.target) })), []);
|
||||
};
|
||||
await audit();
|
||||
assert.equal(await dynamic.locator('#qa-tab-desktop').isVisible(), true);
|
||||
await dynamic.setViewportSize({ width: 390, height: 850 });
|
||||
await dynamic.locator('#qa-tab-mobile').waitFor({ state: 'visible' });
|
||||
await dynamic.locator('#qa-menu > .menu-burger > summary').click(); await audit();
|
||||
await dynamic.locator('#qa-tab-mobile-tab').focus(); await dynamic.keyboard.press('ArrowRight');
|
||||
assert.equal(await dynamic.locator('#qa-tab-all').isVisible(), true);
|
||||
await dynamic.keyboard.press('ArrowRight'); assert.equal(await dynamic.locator('#qa-tab-mobile').isVisible(), true);
|
||||
const controls = dynamic.locator('#qa-slider + .slider-controls');
|
||||
await controls.scrollIntoViewIfNeeded();
|
||||
assert.match(await controls.locator('.slider-position').innerText(), /\/ 2$/);
|
||||
assert.equal(await controls.getByRole('button', { name: 'Folie 1 anzeigen' }).isVisible(), false);
|
||||
await controls.getByRole('button', { name: 'Nächste Folie' }).click();
|
||||
await dynamic.waitForFunction(() => document.querySelector('#qa-slider + .slider-controls .slider-position').textContent === '2 / 2');
|
||||
await enhanced.close();
|
||||
checks.push('Enhanced groups: tabs select a visible panel and skip hidden tabs; slider skips hidden slides and counts visible ones; desktop/mobile WCAG checks of navigation, tabs and slider controls');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Responsive navigation checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -42,7 +42,7 @@ final class DocumentValidator
|
||||
public function validate(array $document, bool $requireHeading = true): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -111,6 +111,10 @@ final class DocumentValidator
|
||||
$layout = $block['layout'] ?? [];
|
||||
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
|
||||
foreach ($layout as $key => $value) {
|
||||
if ('visibility' === $key) {
|
||||
$this->require($schemaVersion >= 22 && in_array($value, ['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'], true), 'Unbekannte Sichtbarkeit oder Dokumentschema vor v22.');
|
||||
continue;
|
||||
}
|
||||
if ('button' === $key) {
|
||||
$this->require($schemaVersion >= 21 && 'button' === $type && is_array($value), 'Button-Varianten brauchen einen Button und Schema v21.');
|
||||
foreach ($value as $device => $settings) {
|
||||
@@ -256,6 +260,9 @@ final class DocumentValidator
|
||||
// The entries live in the menu administration; the block only says which slot to show
|
||||
// and how to lay it out.
|
||||
$this->require(isset(MenuRepository::SLOTS[$props['slot'] ?? '']), 'Unknown menu slot.');
|
||||
if (array_key_exists('collapse', $props)) {
|
||||
$this->require($schemaVersion >= 22 && in_array($props['collapse'], ['tablet', 'mobile', 'never'], true), 'Unbekannte Hamburger-Umschaltung oder Dokumentschema vor v22.');
|
||||
}
|
||||
if (isset($props['layout'])) {
|
||||
$this->require($schemaVersion >= 16, 'Die Menüart braucht Schema v16.');
|
||||
$this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.');
|
||||
|
||||
@@ -8,4 +8,17 @@ use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
|
||||
private ?string $releaseCacheDir = null;
|
||||
|
||||
public function getCacheDir(): string
|
||||
{
|
||||
// A web update still needs its compiled services until the response has finished.
|
||||
// The next release builds its own cache without deleting the running request's files.
|
||||
return $this->releaseCacheDir ??= parent::getCacheDir().'/release-'.substr(
|
||||
hash_file('sha256', $this->getProjectDir().'/VERSION'),
|
||||
0,
|
||||
16,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -218,9 +218,9 @@ final class ArchiveUpdate
|
||||
}
|
||||
$melden($schritte, 'Dateien ersetzen', true, sprintf('%d Einträge ersetzt, var/ und .env unberührt.', $ersetzt));
|
||||
|
||||
// Der Zwischenspeicher gehört zum alten Stand.
|
||||
$this->removeDirectory($this->root.'/var/cache');
|
||||
$melden($schritte, 'Zwischenspeicher', true, 'Geleert - sonst liefe die neue Fassung mit den Wegweisern der alten.');
|
||||
// Never delete compiled services underneath the running kernel. The new kernel uses
|
||||
// a release-specific cache directory; old requests can finish with their own files.
|
||||
$melden($schritte, 'Zwischenspeicher', true, 'Die neue Fassung verwendet einen eigenen Cache; laufende Anfragen behalten ihren bisherigen.');
|
||||
|
||||
// Hier endet dieser Durchgang mit Absicht. Der laufende Prozess hat den alten Code im
|
||||
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
|
||||
@@ -341,19 +341,4 @@ final class ArchiveUpdate
|
||||
|
||||
return $treffer[1];
|
||||
}
|
||||
|
||||
private function removeDirectory(string $pfad): void
|
||||
{
|
||||
if (!is_dir($pfad)) {
|
||||
return;
|
||||
}
|
||||
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||||
if ('.' === $eintrag || '..' === $eintrag) {
|
||||
continue;
|
||||
}
|
||||
$voll = $pfad.'/'.$eintrag;
|
||||
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
|
||||
}
|
||||
@rmdir($pfad);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@
|
||||
<div class="panel-resizer" data-resize="left" role="separator" tabindex="0" aria-label="Linke Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="240" aria-valuemax="560" aria-valuenow="280" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
|
||||
<section class="canvas-panel" aria-label="Live-Vorschau">
|
||||
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><button id="preview-motion" type="button" class="text-button" aria-pressed="false">Bewegung testen</button>{% if not area %}<a href="{{ page.path == 'home' ? '/' : '/' ~ page.path }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a>{% endif %}</div>
|
||||
<p id="motion-status" class="preview-status" role="status" hidden>Dein Browser meldet „Bewegung reduzieren“. Einblendungen, Hover-Bewegungen und Slider-Automatik sind deshalb deaktiviert.</p>
|
||||
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
|
||||
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav>
|
||||
<div class="canvas-scroll" tabindex="0" role="region" aria-label="Vorschau scrollen"><div id="preview-stage"><iframe id="preview" title="Vorschau der bearbeiteten Seite" sandbox="allow-same-origin" class="viewport-desktop"></iframe></div></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% set type = block.type %}
|
||||
{% set props = block.props|default({}) %}
|
||||
{% set layout = block.layout|default({}) %}
|
||||
{% set designClasses = '' %}
|
||||
{% set designClasses = layout.visibility|default('all') != 'all' ? ' visibility-' ~ layout.visibility : '' %}
|
||||
{% for device, settings in layout.design|default({}) %}
|
||||
{% for key, value in settings %}{% set designClasses = designClasses ~ ' d-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}
|
||||
{% endfor %}
|
||||
@@ -68,7 +68,7 @@
|
||||
</figure>
|
||||
{% elseif type == 'logos' %}
|
||||
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
|
||||
{% for child in block.children|default([]) %}<li>{% 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), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</li>{% endfor %}
|
||||
{% for child in block.children|default([]) %}<li class="visibility-{{ child.layout.visibility|default('all') }}">{% 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), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</li>{% endfor %}
|
||||
</ul>
|
||||
{% elseif type == 'cta' %}
|
||||
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
|
||||
@@ -93,14 +93,17 @@
|
||||
{% if links is not empty %}
|
||||
{# The layout is a class, and the burger variant is a details element: a menu that needs a
|
||||
script to open would be a menu that does not open on a page without one. #}
|
||||
<nav id="{{ block.id }}" class="block-menu menu-{{ props.layout|default('horizontal') }}{{ designClasses }}"
|
||||
{% set menuLayout = props.layout|default('horizontal') %}
|
||||
{% set collapse = menuLayout == 'horizontal' ? props.collapse|default('tablet') : 'never' %}
|
||||
<nav id="{{ block.id }}" class="block-menu menu-{{ menuLayout }} menu-collapse-{{ collapse }}{{ designClasses }}"
|
||||
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation') }}">
|
||||
{% if props.layout|default('horizontal') == 'burger' %}
|
||||
{% if menuLayout == 'burger' or collapse != 'never' %}
|
||||
<details class="menu-burger">
|
||||
<summary aria-label="Menü öffnen"><span class="burger-lines" aria-hidden="true"></span><span class="burger-text">Menü</span></summary>
|
||||
<summary><span class="burger-lines" aria-hidden="true"></span><span class="burger-text">Menü</span></summary>
|
||||
<div class="menu-burger-body">{% include 'nav.html.twig' with {links: links} only %}</div>
|
||||
</details>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
{% if menuLayout != 'burger' %}
|
||||
{% include 'nav.html.twig' with {links: links} only %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -114,7 +117,7 @@
|
||||
{% for child in section.blocks %}{% 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), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif editing|default(false) %}
|
||||
<p id="{{ block.id }}" class="block-unavailable" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
|
||||
<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
|
||||
{% else %}
|
||||
<!-- Verknüpfte Sektion {{ props.partial }} ist nicht vorhanden. -->
|
||||
{% endif %}
|
||||
@@ -138,7 +141,7 @@
|
||||
tabs.js turns it into a tab widget; it only changes presentation, never content. #}
|
||||
<div id="{{ block.id }}" class="block-tabs{{ designClasses }}" data-tabs>
|
||||
<ul class="tabs-nav">
|
||||
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
|
||||
{% for child in block.children|default([]) %}<li class="visibility-{{ child.layout.visibility|default('all') }}"><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
|
||||
</ul>
|
||||
{% 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), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
@@ -184,7 +187,7 @@
|
||||
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}{{ props.dots ?? false ? ' slider-dots' : '' }}"{% if props.autoplay|default(0) >= 3 %} data-autoplay="{{ props.autoplay }}"{% endif %}
|
||||
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
|
||||
{% for child in block.children|default([]) %}
|
||||
<div class="slider-slide">{% 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), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</div>
|
||||
<div class="slider-slide visibility-{{ child.layout.visibility|default('all') }}">{% 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), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'embed' %}
|
||||
@@ -246,5 +249,5 @@
|
||||
editor preview shows a placeholder so the block can still be found and moved.
|
||||
#}
|
||||
<!-- Block {{ block.id }} ({{ type }}) wird von dieser Installation nicht dargestellt. -->
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable" role="note">Baustein „{{ type }}“ ist in dieser Installation nicht verfügbar. Der Inhalt bleibt gespeichert.</p>{% endif %}
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Baustein „{{ type }}“ ist in dieser Installation nicht verfügbar. Der Inhalt bleibt gespeichert.</p>{% endif %}
|
||||
{% endif %}
|
||||
@@ -58,6 +58,6 @@
|
||||
<noscript><p class="field-error">Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.</p></noscript>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
<button class="button" type="submit">Absenden</button>
|
||||
<button class="button cms-button" type="submit">Absenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -70,10 +70,12 @@ final class ArchiveUpdateTest extends TestCase
|
||||
public function testAnUpdateReplacesTheApplicationAndLeavesTheInstallationUntouched(): void
|
||||
{
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
$this->write('var/cache/prod/running-service.php', '<?php // Still needed by this request.');
|
||||
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
|
||||
self::assertFileExists($this->root.'/var/cache/prod/running-service.php', 'The update request still loads compiled services after replacing files.');
|
||||
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
|
||||
self::assertSame(
|
||||
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
|
||||
|
||||
+7
-4
@@ -574,7 +574,8 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('href="https://hausklar.app"', $html);
|
||||
self::assertStringContainsString('href="tel:+4972219957150"', $html);
|
||||
// The home link is the current page; an anchor inside it is not.
|
||||
self::assertSame(1, substr_count($html, 'aria-current="page"'));
|
||||
// Desktop and collapsed menu carry the same marker; CSS exposes one variant at a time.
|
||||
self::assertSame(2, substr_count($html, 'aria-current="page"'));
|
||||
self::assertMatchesRegularExpression('~<a href="/" aria-current="page">\s*<span class="nav-label"><span>Start</span></span>\s*</a>~', $html);
|
||||
|
||||
// On another page the marker moves and nothing else changes.
|
||||
@@ -2569,7 +2570,7 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('<header class="site-header">', $html);
|
||||
self::assertStringContainsString('<p id="area-header-name" class="block-text">kantsites</p>', $html);
|
||||
self::assertStringContainsString('aria-label="Hauptnavigation"', $html);
|
||||
self::assertStringContainsString('>Start</a>', $html);
|
||||
self::assertStringContainsString('<span class="nav-label"><span>Start</span></span>', $html);
|
||||
|
||||
// Changing the header reaches every page at once, because it is one shared document.
|
||||
$header = $this->partials->findByKind('header');
|
||||
@@ -2699,7 +2700,8 @@ final class CmsTest extends TestCase
|
||||
self::assertSame('_blank', $crawler->filter($selector)->attr('target'));
|
||||
self::assertSame('noopener', $crawler->filter($selector)->attr('rel'));
|
||||
}
|
||||
self::assertCount(2, $crawler->filter('.site-header .menu-list a[target="_blank"][rel="noopener"]'));
|
||||
self::assertCount(2, $crawler->filter('.site-header .block-menu > .menu-list a[target="_blank"][rel="noopener"]'));
|
||||
self::assertCount(2, $crawler->filter('.site-header .menu-burger a[target="_blank"][rel="noopener"]'));
|
||||
$document['blocks'][array_key_last($document['blocks'])]['props']['newTab'] = 'yes';
|
||||
$this->expectException(InvalidDocument::class);
|
||||
(new DocumentValidator())->validate($document);
|
||||
@@ -3295,7 +3297,8 @@ final class CmsTest extends TestCase
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('class="block-menu menu-'.$layout, $html);
|
||||
// The burger opens with a details element, so it works on a page that runs no script.
|
||||
self::assertSame('burger' === $layout, str_contains($html, 'menu-burger'));
|
||||
$crawler = new \Symfony\Component\DomCrawler\Crawler($html);
|
||||
self::assertCount('vertical' === $layout ? 0 : 1, $crawler->filter('#nav > details.menu-burger'));
|
||||
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
// The blank row is an add slot, not an entry.
|
||||
self::assertCount(2, $items);
|
||||
|
||||
$public = $client->request('GET', '/')->filter('nav[aria-label="Hauptnavigation"] a');
|
||||
$public = $client->request('GET', '/')->filter('nav[aria-label="Hauptnavigation"] > .menu-list a');
|
||||
// The navigation comes from the menu now, not from the links hard-coded in the template.
|
||||
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
|
||||
}
|
||||
@@ -367,6 +367,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
|
||||
$html = $client->request('GET', '/')->html();
|
||||
self::assertStringContainsString('action="/formular/kontakt"', $html);
|
||||
self::assertStringContainsString('<button class="button cms-button" type="submit">Absenden</button>', $html);
|
||||
self::assertStringContainsString('Ich willige ein.', $html);
|
||||
// The honeypot exists but is kept out of the tab order and away from assistive technology.
|
||||
self::assertStringContainsString('name="website"', $html);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Kernel;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class KernelCacheTest extends TestCase
|
||||
{
|
||||
public function testAnUpdateKeepsTheRunningCacheAndSelectsANewOneForTheNextRequest(): void
|
||||
{
|
||||
$root = sys_get_temp_dir().'/kant-cache-'.bin2hex(random_bytes(6));
|
||||
mkdir($root);
|
||||
$kernel = static fn (): Kernel => new class($root) extends Kernel {
|
||||
public function __construct(private string $root)
|
||||
{
|
||||
parent::__construct('prod', false);
|
||||
}
|
||||
|
||||
public function getProjectDir(): string
|
||||
{
|
||||
return $this->root;
|
||||
}
|
||||
};
|
||||
try {
|
||||
file_put_contents($root.'/VERSION', "1.10.1\n");
|
||||
$running = $kernel();
|
||||
$old = $running->getCacheDir();
|
||||
self::assertSame($old, $kernel()->getCacheDir());
|
||||
file_put_contents($root.'/VERSION', "1.10.2\n");
|
||||
self::assertSame($old, $running->getCacheDir(), 'The active request retains its cache even after VERSION changes.');
|
||||
self::assertNotSame($old, $kernel()->getCacheDir(), 'A new kernel must not reuse the previous compiled container or Twig templates.');
|
||||
} finally {
|
||||
unlink($root.'/VERSION');
|
||||
rmdir($root);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Domain\InvalidDocument;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ResponsiveVisibilityTest extends TestCase
|
||||
{
|
||||
private function document(int $version = 22): array
|
||||
{
|
||||
return ['schemaVersion' => $version, 'title' => 'Geräte', 'description' => 'Geräteprüfung', 'blocks' => [
|
||||
['id' => 'heading', 'type' => 'heading', 'props' => ['text' => 'Geräte', 'level' => 1]],
|
||||
['id' => 'menu', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal']],
|
||||
]];
|
||||
}
|
||||
|
||||
public function testOldDocumentsAndEveryVisibilityAndMenuOptionRemainValid(): void
|
||||
{
|
||||
$validator = new DocumentValidator();
|
||||
$validator->validate($this->document(21));
|
||||
foreach (['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'] as $visibility) {
|
||||
foreach (['tablet', 'mobile', 'never'] as $collapse) {
|
||||
$document = $this->document();
|
||||
$document['blocks'][1]['layout'] = ['visibility' => $visibility];
|
||||
$document['blocks'][1]['props']['collapse'] = $collapse;
|
||||
$validator->validate($document);
|
||||
self::assertSame($document, json_decode(json_encode($document, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testInvalidValuesAndOlderSchemasRejectNewOptions(): void
|
||||
{
|
||||
foreach ([['visibility', 'desktop', 21], ['collapse', 'tablet', 21], ['visibility', 'injected class', 22], ['collapse', 'sometimes', 22], ['visibility', [], 22], ['collapse', null, 22]] as [$key, $value, $version]) {
|
||||
$document = $this->document($version);
|
||||
$document['blocks'][1]['visibility' === $key ? 'layout' : 'props'][$key] = $value;
|
||||
try {
|
||||
(new DocumentValidator())->validate($document);
|
||||
self::fail('Ungültige Geräteoption wurde akzeptiert.');
|
||||
} catch (InvalidDocument $error) {
|
||||
self::assertNotEmpty($error->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EditorHistory, setVisibility, locate, duplicateBlock } from '../public/assets/editor-model.js';
|
||||
|
||||
test('visibility preserves responsive layout and migrates, undoes, redoes and resets atomically', () => {
|
||||
const original = { schemaVersion: 21, title: 'Geräte', description: '', blocks: [{ id: 'section', type: 'section', layout: { width: 'full' }, style: { desktop: { display: 'flex' }, mobile: { display: 'grid' } }, children: [] }] };
|
||||
const history = new EditorHistory(original);
|
||||
history.change(doc => setVisibility(doc, 'section', 'mobile'));
|
||||
assert.equal(history.document.schemaVersion, 22);
|
||||
assert.equal(locate(history.document.blocks, 'section').block.layout.visibility, 'mobile');
|
||||
history.undo(); assert.deepEqual(history.document, original);
|
||||
history.redo(); assert.equal(history.document.blocks[0].layout.visibility, 'mobile');
|
||||
const duplicate = history.change(doc => duplicateBlock(doc, 'section'));
|
||||
assert.equal(locate(history.document.blocks, duplicate).block.layout.visibility, 'mobile');
|
||||
history.change(doc => setVisibility(doc, 'section', 'all'));
|
||||
assert.deepEqual(history.document.blocks[0], original.blocks[0]);
|
||||
assert.throws(() => setVisibility(history.document, 'section', 'invalid'));
|
||||
});
|
||||
Reference in new issue
Block a user