Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c1c86b864 | ||
|
|
c82a37643b | ||
|
|
cff468167b | ||
|
|
f182f9e125 | ||
|
|
37c2942d1d | ||
|
|
eea6747d9e | ||
|
|
3391fea58b | ||
|
|
bb4e422af8 | ||
|
|
81276ddd1a | ||
|
|
d300eae241 | ||
|
|
a3029966cb | ||
|
|
a108e7d143 | ||
|
|
55dab4bc1e | ||
|
|
c1b152b376 | ||
|
|
8f475d1374 | ||
|
|
ab86312e87 | ||
|
|
16da844934 | ||
|
|
a3c89bb69d |
No files matched your search
@@ -4,6 +4,41 @@ 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.
|
||||
|
||||
Sektionen, Zeilen und Spalten haben im Inhaltsbereich das Feld „Sprungziel“. Dort beispielsweise `kontakt` eintragen und einen Link auf `#kontakt` setzen. Ziele sind eindeutig; die internen Block-IDs bleiben erhalten. Duplikate erhalten kein kopiertes Sprungziel.
|
||||
|
||||
Menüeinträge am Punktgriff ziehen oder per Pfeiltasten verschieben und einrücken. Optional ein Symbol vor oder nach dem Text auswählen. Änderungen anschließend mit „Menü speichern“ übernehmen. Neben der Fassung links erscheint für Administratoren ein Link, sobald die bestehende tägliche Updateprüfung eine neuere Version gefunden hat. Der Aufgaben-Worker muss dafür laufen; die Sidebar selbst führt keine Netzwerkanfrage aus.
|
||||
|
||||
Gezielte Entwicklungsprüfungen: `npm run test:buttons` und `npm run test:navigation-ux` verwenden eine isolierte Kant-IT-Kopie. Responsive Button-CSS nach Änderungen mit `npm run build:buttons` erzeugen.
|
||||
|
||||
## Lokal starten
|
||||
|
||||
Voraussetzungen: PHP ab 8.2 mit Composer 2, PDO SQLite, Ctype, Iconv, Session, XML und GD mit WebP-Unterstützung. EXIF wird für die automatische Ausrichtung von JPEG-Fotos empfohlen. Für Tests zusätzlich die von `composer install` geprüften Erweiterungen, insbesondere DOM, XMLWriter und Mbstring. Für Hosting eine aktuell gepflegte PHP-Patchversion verwenden. Node 24 wird für Browserprüfungen und das Generieren der Design-CSS gebraucht, nicht im Hostingbetrieb.
|
||||
@@ -22,8 +57,24 @@ Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht op
|
||||
|
||||
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
|
||||
|
||||
## Eigene 404-Seite (ab 1.8.0)
|
||||
|
||||
Unter **Einstellungen → 404-Seite** eine vorhandene Seite per Livesuche auswählen und **404-Zuordnung speichern**. Oder **404-Vorlage anlegen** anklicken, den angelegten Entwurf im Editor gestalten und veröffentlichen. Die Ausgabe übernimmt den Standard-Header, Standard-Footer, Schriften und Farben der Website. **404-Vorschau** öffnet die tatsächlich sichtbare Fehlerseite.
|
||||
|
||||
Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website-Design. Entwürfe bleiben privat; Umbenennen erhält die Zuordnung, nach dem Löschen greift wieder der Standard. Das Entfernen der Auswahl setzt die Zuordnung zurück und erhält den Seiteninhalt. Die Zuordnung benötigt Veröffentlichungsrechte. Unbekannte URLs bleiben unverändert und antworten mit HTTP 404; die Fehlerseite wird nicht in Sitemap/llms.txt aufgenommen. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:404`, Berichte und Screenshots unter `reports/not-found/`. Datenvertrag und Rückweg: ADR 082.
|
||||
|
||||
## 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/`.
|
||||
|
||||
**Farbauswahl im Editor (ab 1.8.2):** Unter Design zeigt jedes Farbfeld eine Vorschau mit Palettenname und Hexwert. Anklicken öffnet die Website-Farben als Farbkacheln. Eigene Farben über den Farbwähler oder als Hexwert eingeben und mit Enter/✓ übernehmen. Transparente Hexfarben werden auf einem Schachbrett dargestellt. „Geerbten Wert verwenden“ entfernt die eigene Überschreibung für das aktive Gerät. Prüfen: `npm run test:colors` mit isolierten Kant-IT-Daten; Screenshots unter `reports/color-picker/`.
|
||||
|
||||
**Footer-Links (ab 1.8.1):** Datenschutz, Impressum und weitere Links werden selbst im Footer oder Seiteninhalt platziert. Das CMS hängt keine zusätzliche Rechtszeile an. Der optionale Baustein „Rechtliches“ verwendet die Adressen aus den Einstellungen und erscheint nur an seiner eingefügten Position.
|
||||
|
||||
Für eine neue Installation im interaktiven Terminal ein Konto anlegen:
|
||||
|
||||
```powershell
|
||||
@@ -44,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.
|
||||
|
||||
@@ -139,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.
|
||||
|
||||
@@ -40,6 +40,9 @@ services:
|
||||
App\EventSubscriber\AdminHeadersSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\EventSubscriber\PublicNotFoundSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Content\Statistics:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
|
||||
@@ -2,6 +2,94 @@
|
||||
|
||||
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.
|
||||
|
||||
Benannte `anchor`-Werte sind nur an Sektion, Zeile und Spalte erlaubt und werden gegen alle Block-IDs, andere Ziele und reservierte IDs geprüft. Ein zusätzliches Zielelement erhält den Namen; die stabile Block-ID bleibt erhalten. Duplizieren entfernt benannte Ziele aus der Kopie. Native Fragmentlinks funktionieren ohne öffentliches JavaScript.
|
||||
|
||||
Menüeinträge speichern optionale `icon`- und `iconPosition`-Werte aus der bestehenden SVG-Allowlist. Drag-and-drop verwendet denselben Baum und Undo-/Speicherweg wie Tastaturbefehle; Unterpunkte reisen mit dem Hauptpunkt. Die Versionsanzeige liest nur das vorhandene Ergebnis des täglichen Update-Workers. Dadurch entstehen beim Öffnen von Adminseiten keine zusätzlichen externen Anfragen.
|
||||
|
||||
**Migration:** Validator und Editor unterstützen Schema 21 zusätzlich zu bisherigen Fassungen. Erst eine neue Button-Variante oder ein Sprungziel hebt das bearbeitete Dokument auf 21; vorhandene Dokumente werden nicht pauschal umgeschrieben. Globale Buttons und Menü-Icons sind optionale Konfigurationsfelder, keine SQL-Migration.
|
||||
|
||||
**Rückweg:** Vor einem Downgrade eine Sicherung erstellen bzw. die Sicherung vor 1.9.0 wiederherstellen. Alternativ Schema-21-Dokumente exportieren, `anchor` und `layout.button` rekursiv entfernen und die Fassung auf 20 setzen, anschließend mit dem alten Validator prüfen/importieren. Alte Software kennt die neuen Konfigurationsfelder nicht; globale Button-Vorgaben/Icons gegebenenfalls aus der Sicherung entfernen, sie können beim alten Speichern verloren gehen. Sprunglinks auf entfernte Ziele müssen angepasst werden.
|
||||
|
||||
## ADR 086 – Bildausrichtung berücksichtigt Rahmen, Bild und Layoutachse (14.09.2026)
|
||||
|
||||
`align-self` allein hat in normalen Inhaltsbereichen keine Wirkung. Ein prozentual schmaler Bildrahmen blieb links; bei voller Rahmenbreite konnte zusätzlich das kleinere Bild im Rahmen links stehen. Der Browserfall mit einem 50-Prozent-Bild in einer gepolsterten Spalte reproduzierte die Abweichung trotz ausgewählter Mitte.
|
||||
|
||||
Der gemeinsame StyleCompiler ergänzt bei Bildern mit eigener Ausrichtung horizontale Außenabstände am Rahmen und am enthaltenen Bild sowie `justify-self` für Raster. Verlinkte und vergrößerbare Bilder werden über dasselbe innere Bild erreicht. In horizontalen Flex-Gruppen bleiben die Außenabstände am Rahmen erhalten: Dort wirkt die vorhandene Einstellung auf der vertikalen Querachse und soll die horizontale Verteilung nicht verändern. Sektionen, Akkordeon-Einträge und Slider haben innere Inhaltswrapper, deren Blockfluss berücksichtigt wird.
|
||||
|
||||
Bild- und Elterneinstellungen werden je Gerät geerbt. Änderungen der Flexrichtung werden deshalb auch bei unverändertem Bildwert wirksam. Auto, Strecken und Grundlinie entfernen die zusätzliche horizontale Bildausrichtung und stellen die jeweiligen selbst gesetzten Außenabstände wieder her. Originalmaße, Bildquellen, Bildbreite, Beschriftungen und Links werden nicht umgeschrieben. Beliebiges benutzerdefiniertes CSS kann diese Layoutannahmen weiterhin überschreiben.
|
||||
|
||||
**Migration und Rückweg:** Keine Änderung am Dokument- oder Datenbankschema. Bestehende gespeicherte Ausrichtungswerte werden beim Rendern korrigiert, ohne erneutes Speichern. Vorschau und öffentliche Ausgabe verwenden denselben Compiler; kein zusätzliches öffentliches JavaScript. Ein Rückweg auf 1.8.3 erhält alle Daten, zeigt jedoch dessen bisherige Ausrichtung.
|
||||
|
||||
## ADR 085 – Akkordeon-Inhalte sind im Editor direkt zugänglich (14.09.2026)
|
||||
|
||||
Die bisherige Vorschau verhinderte Standard-Klickaktionen einschließlich des Öffnens von `<details>`. Einträge mit `props.open: false` blieben deshalb geschlossen; für leere Akkordeon-Einträge fehlten zusätzlich die Einfügehilfen. Ein Browserfall reproduzierte den geschlossenen Zustand nach Anklicken des Titels.
|
||||
|
||||
`preview-containers.js` öffnet Akkordeon-Einträge ausschließlich im Vorschau-DOM. Leere Einträge zeigen eine Einfügefläche, gefüllte Einträge eine Schaltfläche zum Anhängen weiterer Inhalte. Eine eigene Aktion am Akkordeon legt Einträge an, auch wenn es vollständig leer ist. Die rechte Inhaltsansicht listet Einträge mit ihren Titeln und bietet dieselben Aktionen. Neu eingefügte Akkordeons wählen ihren ersten Eintrag und den Inhaltsbereich; Struktur und Struktursuche berücksichtigen Titel.
|
||||
|
||||
Die Modellauswahl eines Akkordeons erlaubt direkt nur neue Einträge. Texte und andere Inhalte erfordern die Auswahl des Eintrags, damit sie nicht unbemerkt außerhalb des Akkordeons landen. Der gesonderte Befehl für neue Sektionen auf Seitenebene bleibt erhalten. Explizite Einfügepunkte und Drag-and-drop verwenden weiterhin die bestehenden Eltern-/Kindregeln; „Inhalt hinzufügen“ hängt innerhalb des angesprochenen Eintrags an.
|
||||
|
||||
**Migration und Rückweg:** Kein Schemawechsel und keine Datenmigration. Öffnungszustand, Kinder, IDs und Reihenfolge bleiben normale Blockwerte. Vorschauhilfen werden nie gespeichert; die öffentliche Ausgabe und deren native Tastatur-/Mausbedienung bleiben unverändert. Älterer Code kann die Daten weiterhin lesen, zeigt jedoch wieder die bisherige eingeschränkte Vorschau. Alle Inhaltsänderungen laufen über den vorhandenen Undo-/Speicher-/Veröffentlichungsweg.
|
||||
|
||||
## ADR 084 – Visuelle Palettenauswahl im Editor (14.09.2026)
|
||||
|
||||
Alle Farbfelder des Designbereichs verwenden `color-control.js`: kompakte Vorschau mit Farbnamen und aufgelöstem Hexwert, aufklappbare Farbkacheln, nativer Farbwähler und validierte Hexeingabe. `@key` wird nur für die Darstellung aufgelöst; die gespeicherte Palettenreferenz bleibt erhalten. Gerätespezifische Vererbung und Hover-Werte größerer Geräte sind sichtbar. Ohne zuordenbaren Farbwert zeigt das Feld ein neutrales Schachbrett und „Übernehmen“; es erfindet keine schwarze Farbe. Achtstellige Hexwerte behalten ihren Alphakanal auch beim Ändern des Farbtons. Dreistellige Hexeingaben werden vor dem Speichern in sechsstellige Werte umgewandelt.
|
||||
|
||||
Palettenwahl und bestätigte Texteingaben sind einzelne Undo-Schritte. Eine Bewegung im nativen Wähler aktualisiert die Vorschau mit einem gemeinsamen History-Schlüssel und erhält den DOM-Knoten bis zum Abschluss. Danach werden Gerätemarkierungen, Reset und Kontrasthinweis aktualisiert. Farbauswahl per Tastatur, Escape, Fokuswiederherstellung und lokale Fehlermeldung sind Teil desselben Bedienelements. Die Farbkacheln verwenden beschriftete Buttons mit Auswahlzustand; lange Paletten bleiben innerhalb des Panels scrollbar. Keine zusätzliche Bibliothek und kein öffentliches Skript. Das neue Modul steht in der versionierten Importmap.
|
||||
|
||||
**Migration:** Keine Änderung an Datenbank oder Dokumentformat. Bereits erlaubte sechs-/achtstellige Hexwerte und Palettenreferenzen werden unverändert gespeichert. Ein Rückweg auf 1.8.1 zeigt dieselben Werte im früheren Farbfeld. Verwaltungsformulare außerhalb des visuellen Editors behalten ihre bisherigen Felder.
|
||||
|
||||
## ADR 083 – Footer-Links werden redaktionell platziert (14.09.2026)
|
||||
|
||||
Auf ausdrücklichen Wunsch von Umut entfällt die automatisch angehängte Rechtszeile vollständig. Seiten, Beiträge, Archive, Fehlerseiten und Editorvorschauen geben ausschließlich die selbst platzierten Footer-/Inhaltslinks aus. Die frühere Erkennung des speziellen `legal`-Blocks konnte normale Text- oder Menülinks nicht berücksichtigen; dadurch erschien unter einem vollständig gestalteten Footer ein zusätzlicher heller Streifen mit doppelten Links. Die Template-Ausgabe, ihre Erkennung im Controller und ungenutztes CSS sind entfernt.
|
||||
|
||||
Normale Links, Menüs und bewusst eingefügte „Rechtliches“-Blöcke bleiben erhalten. Der optionale Block nutzt weiterhin die konfigurierten Seitenadressen und den Link zur Einwilligung. Einwilligungsdialog, Einstellungen und der Endpunkt `/einwilligung` behalten ihre Funktion. Keine Inhalte werden umgeschrieben, keine neue Schema- oder Datenbankmigration. Bei einem Rückweg auf ältere Versionen erscheint deren automatische Leiste wieder.
|
||||
|
||||
Diese Entscheidung ersetzt die automatische Verlinkung aus ADR 033 und die bedingte Zusatzleiste aus ADR 062; Position und Gestaltung der Links liegen beim Autor.
|
||||
|
||||
## ADR 082 – Zuweisbare 404-Seite im gemeinsamen Seitenrenderer (14.09.2026)
|
||||
|
||||
`website.settings.notFoundPageId` verweist auf eine normale Seite über deren stabile ID. Eine eigene Einstellung mit Livesuche weist sie zu oder entfernt die Zuordnung; sie benötigt Veröffentlichungsrechte und CSRF-Schutz. Der erwartete bisherige Wert schützt gegen ein veraltetes Formular. Die Änderung liest die aktuellen Einstellungen innerhalb einer Transaktion, unter MySQL/MariaDB mit Zeilensperre, und schreibt nur die geänderte Zuordnung in den erhaltenen JSON-Inhalt. Das normale Unternehmensformular erhält diesen Wert unabhängig von seinen eigenen Feldern. Startseite und 404-Seite dürfen nicht dieselbe Seite sein.
|
||||
|
||||
Ohne verwendbare veröffentlichte Zuordnung wird ein v10-Blockdokument im Speicher erzeugt. Öffentliche GETs legen keine Seiten an. „404-Vorlage anlegen“ erstellt eine bearbeitbare Kopie als Entwurf und weist sie zu; bis zur Veröffentlichung bleibt die Standardausgabe sichtbar. Vorhandene Adressen einschließlich Papierkorb werden erhalten; bei belegtem `/404` wird `/404-2` usw. gewählt. Wiederholtes Anlegen überschreibt keine Seite. Die erzeugte Vorlage erhält `noindex`. Erstellen und Zuweisen laufen in einer gemeinsamen Datenbanktransaktion.
|
||||
|
||||
Normale Seiten, zugewiesene Fehlerseiten und der Standard verwenden denselben Renderer mit Theme-CSS, Schriften, Standard-Header/-Footer, Menüs, verknüpften Sektionen und Einwilligungsanzeige. Auf der Fehlerausgabe haben die Standardbereiche Vorrang vor eventuellen Bereichsabweichungen der ausgewählten Seite. Nur veröffentlichte Revisionen sind öffentlich sichtbar; Umbenennen erhält die Zuordnung, Löschen/nicht veröffentlichte Seiten führen auf den Standard zurück. Die Originaladresse bleibt stehen, die Antwort trägt HTTP 404 und `noindex, follow` in HTML und Header, ohne Canonical oder strukturierte Daten einer normalen Seite. Auch die eigene Adresse der zugewiesenen Seite antwortet mit 404. Sitemap und llms.txt schließen die zugewiesene ID zusätzlich aus, unabhängig von deren normalen Metadaten.
|
||||
|
||||
Ein schmaler Exception-Subscriber übernimmt öffentliche HTML-GET/HEAD-Routingfehler außerhalb der Slug-Grammatik, etwa alte `.html`-Adressen oder tiefere Pfade. Fehler bestehender Controller, Verwaltung, Medien/Assets, Formulare, Installation, AJAX sowie maschinenlesbare Ressourcen bleiben ihren bisherigen Endpunkten überlassen. Normale Seiten- und Archivfehler rufen den Fehlerrenderer direkt auf. 301/410 werden vorher behandelt; 410 nutzt einen eigenen Standardtext und behält seinen Status.
|
||||
|
||||
**Migration und Rückweg:** Keine Datenbankmigration und kein neues Dokumentschema. Fehlender/null Settings-Inhalt erhält beim Lesen den leeren Standardwert. Die Einstellung wird erst beim Zuweisen gespeichert. Die Vorlage nutzt bestehende v10-Blöcke. Älterer Code ignoriert die neue Zuordnung und verwendet wieder seine bisherige 404-Ausgabe; die angelegte Seite und ihre Revisionen bleiben normaler Inhalt. Vor einem Rückweg die Website-Einstellungen sichern, da ältere Einstellungsformulare unbekannte JSON-Schlüssel entfernen können. Kein automatisches Veröffentlichen oder Überschreiben vorhandener Seiten.
|
||||
|
||||
## ADR 081 – Linkziel und sichtbare Medienauswahl (14.09.2026)
|
||||
|
||||
Schema v20 ergänzt optionales `props.newTab: boolean` an Button, Bild, Symbolkarte, Aufruf-Band und Symbol. Fehlend/false öffnet wie bisher im aktuellen Tab; true erzeugt im gemeinsamen Renderer `target="_blank" rel="noopener"`. Derselbe Wert ist als optionales Feld in Menüeinträgen verfügbar; die bestehende Menüversion schützt weiterhin vor veralteten Schreibzugriffen. Der Rich-Text-Filter erlaubt ausschließlich `_blank` als Ziel und setzt `noopener` selbst. Andere Targets und fremde Attribute werden entfernt. Textlinks bleiben Teil des bisherigen HTML-Vertrags. Bei Bildern entscheidet die gewählte Klickaktion; eine hinterlegte Adresse bleibt bei „Nichts tun“ gespeichert, erzeugt aber keinen Link mehr.
|
||||
@@ -32,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.
|
||||
|
||||
+139
-1
@@ -1,5 +1,143 @@
|
||||
# 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.
|
||||
|
||||
**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)
|
||||
|
||||
**Behoben:** „Eigene Ausrichtung quer: Mitte“ zentriert Bilder jetzt auch in normalen Sektionen und Spalten. Zuvor blieb das Bild trotz `align-self:center` links; der Browserfall mit 50-Prozent-Breite zeigte 181 Pixel Abweichung von der Mitte (`var/image-alignment-before.log`, `reports/image-alignment/before.png`). Der gemeinsame Compiler richtet Rahmen und enthaltenes Bild aus, einschließlich kleiner Logos, Prozent-/Maximalbreiten und Bildlinks. Anfang/Ende, Gerätevererbung und Rücksetzen sind berücksichtigt; horizontale Flex-Gruppen behalten ihre vertikale Querachse. Keine Datenmigration, keine Änderung an gespeicherten Bilddaten, kein zusätzliches öffentliches Skript (ADR 086).
|
||||
|
||||
**Browser geprüft:** `npm run test:image-alignment` mit drei Prüfgruppen auf isolierter Kant-IT-Kopie erfolgreich: echte Layout-Auswahl, Tastatur, Anfang/Mitte/Ende, Undo/Redo, gepolsterte Inhaltsbereiche, kleine Originalbilder, Prozent-/Maximalbreiten, Link/Vergrößerung, Grid, beide Flexachsen, responsive Elterneinstellungen und Rücksetzen auf Auto mit wiederhergestelltem Außenabstand. Speichern/Laden erhält Maße und Medienmetadaten. Öffentliche Ausgabe ohne JavaScript auf Desktop 1600, Tablet 900 und Mobil 390 geprüft. Screenshots unter `reports/image-alignment/` visuell geprüft; Originaldaten unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Bestehendes `npm run test:image-size` mit drei Prüfgruppen erfolgreich, einschließlich Größenregler, Gerätewerten, Undo/Redo, Speichern/Laden, Veröffentlichung und Desktop-/Mobil-Barrierefreiheit der Bedienelemente. 32 JavaScript-Tests und Produktionscontainer erfolgreich; PHP-Stilprüfung der drei betroffenen Dateien ohne Befund. Alle 481 PHP-Tests / 3865 Assertions erfolgreich, verteilt auf sämtliche 15 Testklassen mit jeweils eigenem Prozess (`php -d max_execution_time=0 vendor/bin/phpunit tests/<Klasse>Test.php`). Ergebnis: `var/phpunit-1.8.4-result.json`, Einzellogs unter `var/phpunit-1.8.4-classes/`. Der Aufgaben-Worker setzt im gemeinsamen Prozess erneut ein 80-Sekunden-Limit; deshalb wurde die Suite pro Klasse ausgeführt. Ein vorhandener CRLF/LF-abhängiger Quelltextvergleich der Layout-Gruppen ist jetzt zeilenumbruchunabhängig. Kein Anwendungscode wurde für die Testlaufzeit geändert.
|
||||
|
||||
**Veröffentlicht:** Commit `81276dd` und Tag `v1.8.4` auf `origin`; [Release 1.8.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.4) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 213 Paketquelldateien gegen Commit/Vorlage verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `4e7b429381cadcc5d9ed857b03917c3abab0197c37fb47e570d03bf50d69eea4`.
|
||||
|
||||
**Nächster Schritt / Grenzen:** Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert. Keine neue Lighthouse-Messung oder vollständige Editor-Gesamtprüfung für die gezielte CSS-Compiler-Korrektur; eigene CSS-Regeln können weiterhin Layoutvorgaben überschreiben.
|
||||
|
||||
## 1.8.3 – Inhalte in Akkordeons sichtbar bearbeiten (14.09.2026)
|
||||
|
||||
**Behoben:** Geschlossene Akkordeon-Einträge ließen sich in der Editorvorschau nicht öffnen, weil die Klickbehandlung die native Aktion verhinderte. Der Fehler wurde vor der Korrektur im Browser reproduziert (`var/accordion-before.log`, `reports/accordion/before.png`). Einträge sind im Editor jetzt geöffnet, leere Inhaltsbereiche sichtbar und als Ablageziel nutzbar. „Inhalt hinzufügen“ im Eintrag und rechts im Feinschliff hängt neue Elemente an. „Eintrag hinzufügen“ steht an der Gruppe und in deren Inhaltsansicht; auch ein vollständig leeres Akkordeon ist wieder befüllbar.
|
||||
|
||||
**Bedienung:** Rechte Eintragsübersicht, Struktur und Struktursuche zeigen Eintragstitel. Ein neues Akkordeon wählt den ersten Eintrag samt Inhaltsoptionen. Bei Auswahl der Gruppe werden Texte nicht mehr unbemerkt außerhalb des Akkordeons eingefügt. Die gespeicherte Einstellung „Beim Laden geöffnet“ gilt weiterhin für Besucher; Öffnen im Editor verändert weder Daten noch Speicherstatus. Keine Migration und kein geänderter öffentlicher Renderer (ADR 085).
|
||||
|
||||
**Geprüft:** `npm run test:accordion` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: sichtbarer Inhalt trotz geschlossenem Besucherzustand, Einfügen über Tastatur/Vorschau/Feinschliff, Textbearbeitung, Anhängen und Drag-and-drop von Buttons in Einträge, Undo/Redo, weitere/leere/verschachtelte Akkordeons, Suche nach Titeln, Speichern/Laden und Veröffentlichung. Veröffentlichte Einträge behalten ihre Öffnungswerte und lassen sich ohne JavaScript per Maus/Enter bedienen; Vorschauhilfen fehlen im öffentlichen HTML und in gespeicherten Daten. Desktop 1600/Mobil 390 ohne axe-Verstöße im Editor oder horizontalen Überlauf; Screenshots visuell geprüft (`reports/accordion/`). Originaldaten unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Medien, Einwilligung und Desktop-/Mobil-Barrierefreiheit. 32 JavaScript-Tests erfolgreich, darunter ergänzte Einfüge-/Undo-Prüfungen. 16 gezielte PHP-Tests / 33 Assertions zu V6-Blockvalidierung, Speichern und Ausgabe ohne JavaScript erfolgreich. Produktionscontainer gültig und Git-Diff ohne Whitespacefehler. Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Editoränderung.
|
||||
|
||||
**Veröffentlicht:** Commit `a302996` und Tag `v1.8.3` auf `origin`; [Release 1.8.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.3) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 212 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `7a3625609dc55f00354ee4b853c2816f20467cd04d0aa9acde7d117c69bd897a`.
|
||||
|
||||
**Nächster Schritt:** Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.2 – Visuelle Farbauswahl im Editor (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Kompakte Farbvorschau mit Palettenname und aufgelöstem Hexwert statt schwarzem Feld bei `@deep` und einer zusätzlichen Dropdown-Liste. Aufklappbare Website-Farben als beschriftete Farbkacheln mit Auswahlzustand, eigener Farbwähler und Hexeingabe mit Enter/Übernehmen. Transparente Farben auf Schachbrett; Alphakanal bleibt beim Ändern des Farbtons erhalten. Geerbte Geräte- und Hover-Farben erkennbar, einzelne Werte zurücksetzbar. Text-, Hintergrund-, Hover-, Verlaufs-, Rahmen- und Schattenfarben nutzen dasselbe Bedienelement. Keine Änderung am Datenvertrag oder öffentlichen Paket (ADR 084).
|
||||
|
||||
**Geprüft:** `npm run test:colors` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: Token-Vorschau, Auswahl/Kanvas, Tastatur/Tab/Escape/Fokus, Undo/Redo, Geräte-/Hover-Vererbung und Reset, stabiler nativer Farbwähler mit einem Undo-Schritt, reine change-Ereignisse, ungültige Eingaben, Kurzhex, achtstellige Hexwerte, Speichern/Laden und Veröffentlichung mit erhaltener Palettenreferenz. Desktop 1600/Mobil 390: geöffnete Farbauswahl und Editor ohne axe-Verstöße oder horizontalen Überlauf; Screenshots `reports/color-picker/` visuell geprüft. Originaldaten unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Gestaltung, Vorlagen, Speichern/Veröffentlichen, Konflikten, Mediathek, Einwilligung sowie Desktop-/Mobil-Barrierefreiheit. Der bisherige Test zur automatisch angehängten Einwilligungszeile wurde an die bereits veröffentlichte Änderung aus 1.8.1 angepasst. 32 JavaScript-Tests einschließlich versionierter Modulimporte bestanden. Geänderte Twig-Basis und Produktionscontainer gültig; Git-Diff ohne Whitespacefehler.
|
||||
|
||||
**Veröffentlicht:** Commit `55dab4b` und Tag `v1.8.2` auf `origin`; [Release 1.8.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.2) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 212 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `f6d2a6ff99f52f3a0939160e26403aca13bb9fb9d7042f38255dd958057ae8f5`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Änderung am Editor. Verwaltungsformulare außerhalb des Editors behalten ihre bisherigen Farbfelder. Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.1 – Keine automatische Rechtszeile unter dem Footer (14.09.2026)
|
||||
|
||||
**Behoben:** Das CMS hängt Datenschutz, Impressum und Einwilligungslink nicht mehr automatisch an den Footer an. Entfernt aus Seiten-/Archiv-/Fehlervorlagen und damit auch aus Seiten- und Bereichsvorschauen. Normale Textlinks, Menüs und ausdrücklich eingefügte „Rechtliches“-Bausteine bleiben erhalten; der Editorhinweis beschreibt die freie Platzierung. Keine Inhaltsänderung oder Migration (ADR 083).
|
||||
|
||||
**Geprüft:** PHP-Vollsuite 477 Tests / 3815 Assertions und 32 JavaScript-Tests erfolgreich. Vorhandene Tests an die redaktionelle Platzierung angepasst; freiwilliger Rechtsblock und Einwilligungsablauf weiterhin geprüft. 47 Twig-Dateien und Produktionscontainer gültig, PHP-Stilprüfung der drei betroffenen PHP-Dateien ohne Änderungen, Git-Diff ohne Whitespacefehler.
|
||||
|
||||
**Browser:** Isolierte Kant-IT-Kopie mit bestehenden Rechtsblöcken sowie einer Variante mit normalen Footer-Textlinks wie im Screenshot. Acht Prüfgruppen erfolgreich: Startseite, Beitragsarchiv und 404 auf Desktop 1600/Mobil 390 sowie Seiten- und Footer-Editor. Eigene Links erhalten, keine Zusatzleiste, kein horizontaler Überlauf. Screenshots unter `reports/footer-links/` visuell geprüft. Gegenprobe mit dem unveränderten Release 1.8.0 reproduziert die doppelte Leiste auf allen sechs öffentlichen Ansichten (`reports/footer-links-before/`). Originaldaten bleiben unverändert.
|
||||
|
||||
**Bekannte Prüfbefunde:** Die Variante mit gewöhnlichen, unformatierten Textlinks meldet Kontrastbefunde an diesen Links; bei sichtbarem Nach-oben-Knopf zusätzlich einen Landmark-Befund. Das Archiv meldet außerdem Überschriftenreihenfolge und doppelte Landmark-Benennung. Alle Befunde betreffen dieselben Elemente wie in der Gegenprobe mit 1.8.0. Keine fehlerfreie Gesamt-Barrierefreiheit oder neuen Lighthouse-Werte behauptet. Keine Wiederholung der gesamten Editor-Bedienprüfung für diese Template-Entfernung.
|
||||
|
||||
**Veröffentlicht:** Commit `8f475d1` und Tag `v1.8.1` auf `origin`; [Release 1.8.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.1) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3534 Archivdateien, 211 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `fc5d7386d1d9b099e1506f16ab7937526014a85a96840c510e7ca4e2703206c3`. Über die Systemverwaltung aktualisieren; Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.0 – Eigene 404-Seite mit Website-Design (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Einstellungen → 404-Seite mit Livesuche, bestehende Seite zuweisen, Auswahl zurücksetzen, direktem Bearbeiten und öffentlicher 404-Vorschau. „404-Vorlage anlegen“ erstellt und verknüpft einen bearbeitbaren Entwurf, ohne vorhandene Seiten zu überschreiben. Bis zur Veröffentlichung erscheint eine automatische Standard-404. Normaler Seitenrenderer übernimmt Standard-Header/-Footer, Theme, Schriften, Menüs und verknüpfte Sektionen. Die ID-Verknüpfung übersteht Umbenennen; Löschen führt auf den Standard zurück. Nur veröffentlichte Revisionen werden öffentlich ausgeliefert.
|
||||
|
||||
**Antwortverhalten:** Echte 404-Antwort an der ursprünglichen URL, auch bei ungewöhnlichen/tiefen HTML-Adressen und beim direkten Aufruf der zugewiesenen Seite. Kein Canonical und keine strukturierten Daten einer normalen Seite; `noindex, follow` in HTML und Header. Ausschluss der zugewiesenen ID aus Sitemap und llms.txt. 301/410, Verwaltungs-/Medien-/Formularendpunkte und maschinenlesbare Ressourcen behalten ihre eigene Behandlung. Zuweisung mit Veröffentlichungsrechten, CSRF und Schutz gegen veraltete Formulare. Keine Datenbankmigration; optionaler Settings-Schlüssel und vorhandenes Dokumentschema v10 (ADR 082).
|
||||
|
||||
**Geprüft:** PHP-Vollsuite 477 Tests / 3817 Assertions erfolgreich, einschließlich neuer 404-Fälle zu Entwurf/Veröffentlichung, Umbenennen, Löschen/Fallback, bestehenden `/404`-Seiten, Schreibkonflikten, Berechtigungen, CSRF, HEAD, Bereichen, 301/410, Sitemap und llms.txt. 32 JavaScript-Tests erfolgreich. `npm run test:404` mit isolierter Kant-IT-Kopie: Vorlage anlegen, im Editor bearbeiten, veröffentlichen, tatsächliche Ausgabe, Livesuche/Tastaturwahl, Zuordnung nach Neuladen, Rücksetzen und veraltetes zweites Formular. Desktop 1600 und Mobil 390 ohne Überlauf/axe-Verstöße; öffentliche Startseite weiterhin 200 mit Canonical und ohne axe-Befund. Funktioniert auch ohne JavaScript; Header/Footer/Schrift mit dem Originalpiloten abgeglichen, Screenshots visuell geprüft (`reports/not-found/`). Originaldaten bleiben unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Laden, Veröffentlichung, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit. Alle 47 Twig-Dateien und der Produktionscontainer gültig; PHP-Stilprüfung über 133 Dateien ohne Befund.
|
||||
|
||||
**Veröffentlicht:** Commit `16da844` und Tag `v1.8.0` auf `origin`; [Release 1.8.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.0) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3534 Archivdateien, 211 versionierte Quelldateien gegen den Commit verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `4c90a0256ad827c40ae09db85fbe72a9315f41b181c26f9983d710b646bc64b4`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Keine neuen Lighthouse-Scores für eine absichtlich nicht indexierbare 404-Seite. Keine neuen Firefox-/Safari-/Screenreader- oder MySQL-Läufe; lokale Datenbankprüfungen verwenden SQLite. Nach Veröffentlichung über die Systemverwaltung aktualisieren; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
|
||||
|
||||
## 1.7.4 – URL-Feld bei Bild-Klickaktion zuverlässig einblenden (14.09.2026)
|
||||
|
||||
**Behoben:** „Zu einer Adresse führen“ blendet Linkziel und Tab-Häkchen unmittelbar unter dem Dropdown ein, auch bei Bildern ohne frühere Verlinkung. Inhalts-Auswahlfelder reagieren auf `input` und `change`, ohne doppelte Änderungen. Das Dropdown bleibt beim Umschalten im DOM; Linkfelder werden nur ein-/ausgeblendet, eingegebene Adressen bleiben erhalten. Jede Auswahl bleibt einzeln rückgängig zu machen. Keine neue Schema-/Datenbankmigration, kein geänderter öffentlicher Renderer oder öffentliches Paket. Cachekennung 1.7.4.
|
||||
@@ -8,7 +146,7 @@
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Inhalts-/Design-Auswahl, Undo, Speichern, Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`).
|
||||
|
||||
**Veröffentlichung:** Release-Paket 1.7.4 wird aus dem geprüften Commit gebaut und auf dem bestehenden Git bereitgestellt.
|
||||
**Veröffentlicht:** Commit `25fd388` und Tag `v1.7.4` auf `origin`; [Release 1.7.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.4) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3532 Archivdateien, 209 versionierte Quelldateien gegen den Commit verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `f41ff10ad403a5dccd48e8d2805079dc3ebdfa2966fd548c4c1b0abe6d26a617`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Kein erneuter PHP- oder Lighthouse-Lauf für diese reine Editor-Korrektur. Kundenhostings werden nicht verändert; nach Einspielen des Updates den Editor neu öffnen, damit die versionierten Skripte geladen werden.
|
||||
|
||||
|
||||
+9
-1
@@ -3,6 +3,10 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
@@ -12,7 +16,11 @@
|
||||
"test:admin": "node scripts/admin-quality-check.mjs",
|
||||
"test:search": "node scripts/live-search-check.mjs",
|
||||
"test:image-size": "node scripts/image-size-check.mjs",
|
||||
"test:link-media": "node scripts/link-media-check.mjs"
|
||||
"test:image-alignment": "node scripts/image-alignment-check.mjs",
|
||||
"test:link-media": "node scripts/link-media-check.mjs",
|
||||
"test:404": "node scripts/not-found-check.mjs",
|
||||
"test:colors": "node scripts/color-picker-check.mjs",
|
||||
"test:accordion": "node scripts/accordion-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
@@ -733,3 +733,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-table td{display:block;border:0;padding:0;min-width:0}.field-table td::before{content:attr(data-label);display:block;font-size:12px;font-weight:600;margin-bottom:5px}.field-table .check-cell{text-align:left}
|
||||
}
|
||||
@media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}}
|
||||
|
||||
.version-update{display:block;width:fit-content;margin-top:8px;padding:5px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none}
|
||||
|
||||
.menu-entry summary::after{content:none}.menu-entry summary{list-style:none;gap:10px;padding:10px 14px}.menu-entry summary::-webkit-details-marker{display:none}.menu-drag-handle{display:grid;place-items:center;width:36px;min-width:36px;height:40px;padding:0;border:0;background:transparent;color:#576e60;cursor:grab;touch-action:none}.menu-drag-handle:active{cursor:grabbing}.menu-chevron{flex:none;color:#52665a;transition:transform .15s}.menu-entry details[open] .menu-chevron{transform:rotate(180deg)}.menu-entry-kind{font-size:11px;border:1px solid #dae4dc;border-radius:5px;padding:3px 6px;color:#506659;background:#f7f9f6;white-space:nowrap}.menu-entry-title{display:flex;align-items:center;gap:8px;min-width:0}.menu-entry-title svg{width:19px;height:19px;flex:none}.menu-dragging{opacity:.6;box-shadow:0 4px 16px #203b3426;border-color:#2c6b4f}.menu-icon-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}@media(max-width:560px){.menu-entry summary{padding:8px}.menu-entry-kind{display:none}.menu-icon-fields{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1 @@
|
||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { devices, effectiveStyle, hoverStyle, locate, upgradeDocument, setStyle } from './editor-model.js';
|
||||
|
||||
export function effectiveButton(block, device, defaults) {
|
||||
const result = { ...defaults };
|
||||
for (const name of Object.keys(devices)) {
|
||||
Object.assign(result, block.layout?.button?.[name]);
|
||||
if (name === device) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Change appearance without touching copy, URL, typography, sizing or layout placement. */
|
||||
export function setButtonDesign(document, id, device, values) {
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (block?.type !== 'button' || !Object.hasOwn(devices, device)) return;
|
||||
if (Object.values(values).some(value => value !== '')) upgradeDocument(document, 21);
|
||||
const target = ((block.layout ??= {}).button ??= {});
|
||||
target[device] ??= {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === '') delete target[device][key]; else target[device][key] = value;
|
||||
// A chosen shape/appearance must visibly take effect even on an older hand-styled button.
|
||||
// Other groups (font, width, padding, margins, position) remain untouched. Undo is atomic.
|
||||
const reset = key === 'shape' ? ['borderRadius'] : key === 'appearance' ? ['backgroundColor', 'backgroundImage', 'gradientFrom', 'gradientTo', 'color', 'borderColor', 'borderWidth', 'borderStyle'] : [];
|
||||
for (const property of reset) {
|
||||
setStyle(document, id, device, property, '');
|
||||
setStyle(document, id, device, property, '', { hover: true });
|
||||
}
|
||||
}
|
||||
if (!Object.keys(target[device]).length) delete target[device];
|
||||
if (!Object.keys(target).length) delete block.layout.button;
|
||||
}
|
||||
|
||||
const element = (tag, className, text) => {
|
||||
const node = document.createElement(tag); if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text; return node;
|
||||
};
|
||||
const color = (value, schema) => value?.startsWith('@') ? schema.colors.find(row => row.key === value.slice(1))?.value || '#203b34' : value;
|
||||
|
||||
export function previewVariables(node, values, schema) {
|
||||
const set = (key, value) => node.style.setProperty(`--kb-${key}`, String(value));
|
||||
set('fill', color(values.backgroundColor, schema)); set('ink', color(values.color, schema));
|
||||
set('hover-fill', color(values.hoverBackgroundColor, schema)); set('hover-ink', color(values.hoverColor, schema));
|
||||
const font = schema.fonts.find(font => font.key === values.fontFamily);
|
||||
set('font', font ? `${font.family ? `"${font.family}",` : ''}${font.fallback}` : 'inherit');
|
||||
set('font-size', `${values.fontSize}px`); set('weight', values.fontWeight);
|
||||
set('tracking', `${values.letterSpacing}px`); set('transform', values.textTransform);
|
||||
}
|
||||
|
||||
export function buttonSample(text, values, schema, hover = false) {
|
||||
const sample = element('span', `cms-button b-desktop-shape-${values.shape} b-desktop-appearance-${values.appearance} b-desktop-size-${values.size}${hover ? ' is-hover' : ''}`, text || 'Button');
|
||||
previewVariables(sample, values, schema);
|
||||
return sample;
|
||||
}
|
||||
|
||||
export function renderButtonControls(panel, { block, device, schema, apply }) {
|
||||
const group = element('section', 'button-designer'); group.setAttribute('aria-label', 'Button-Gestaltung');
|
||||
group.append(element('h3', null, 'Button-Gestaltung'));
|
||||
const current = effectiveButton(block, device, schema.buttons);
|
||||
const previews = element('div', 'button-state-previews');
|
||||
for (const [label, hover] of [['Normal', false], ['Beim Überfahren', true]]) {
|
||||
const state = element('div', 'button-state'); state.append(element('span', 'field-help', label));
|
||||
const sample = buttonSample(block.props.text, current, schema, hover);
|
||||
// Preview the block's own free styling as well as its variant and site defaults.
|
||||
for (const [key, definition] of Object.entries(schema.properties)) {
|
||||
if (!definition.css || definition.target || definition.sides || definition.corners || ['position','display','width','height','minWidth','maxWidth','minHeight','maxHeight'].includes(key)) continue;
|
||||
let value = effectiveStyle(block, device, key).value;
|
||||
if (hover) for (const name of Object.keys(devices)) { value = hoverStyle(block, name, key) ?? value; if (name === device) break; }
|
||||
if (value !== undefined && value !== '') sample.style.setProperty(definition.css, definition.type === 'color' ? color(value, schema) : String(value));
|
||||
}
|
||||
state.append(sample); previews.append(state);
|
||||
}
|
||||
group.append(previews);
|
||||
const cards = element('div', 'button-preset-grid'); cards.setAttribute('role', 'group'); cards.setAttribute('aria-label', 'Button-Vorlagen');
|
||||
for (const [index, preset] of schema.buttonPresets.entries()) {
|
||||
const choice = element('button', 'button-preset'); choice.type = 'button'; choice.id = `button-preset-${index}`;
|
||||
choice.setAttribute('aria-label', `Button-Vorlage ${preset.name}`);
|
||||
choice.setAttribute('aria-pressed', String(current.shape === preset.shape && current.appearance === preset.appearance));
|
||||
const sample = buttonSample('Button', { ...schema.buttons, ...preset, size: 'small' }, schema); sample.setAttribute('aria-hidden', 'true');
|
||||
choice.append(sample, element('span', 'button-preset-label', preset.name));
|
||||
choice.addEventListener('click', () => apply({ shape: preset.shape, appearance: preset.appearance }, choice.id)); cards.append(choice);
|
||||
}
|
||||
group.append(cards);
|
||||
for (const [key, options] of Object.entries(schema.buttonOptions)) {
|
||||
const id = `button-${key}`, label = element('label', null, { shape: 'Form', appearance: 'Darstellung', size: 'Button-Größe' }[key]); label.htmlFor = id;
|
||||
const select = element('select'); select.id = id;
|
||||
select.append(new Option(`Übernehmen: ${options[current[key]]}`, ''));
|
||||
for (const [value, name] of Object.entries(options)) select.append(new Option(name, value));
|
||||
select.value = block.layout?.button?.[device]?.[key] ?? '';
|
||||
select.addEventListener('change', () => apply({ [key]: select.value }, id)); group.append(label, select);
|
||||
}
|
||||
const reset = element('button', 'secondary full-width', device === 'desktop' ? 'Website-Standard übernehmen' : 'Größeres Gerät übernehmen'); reset.type = 'button'; reset.id = 'button-reset';
|
||||
reset.addEventListener('click', () => apply({ shape: '', appearance: '', size: '' }, reset.id));
|
||||
group.append(reset, element('p', 'field-help', `${devices[device]}: Form und Darstellung übernehmen die Website-Vorgaben, solange kein eigener Wert gesetzt ist. Vorlagen ersetzen eigene Füllung und Rahmen; Schrift, Breite und Abstände bleiben erhalten.`));
|
||||
panel.append(group);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Shared by the public renderer and the actual button samples in the editor. */
|
||||
.cms-button{display:inline-flex;align-items:center;justify-content:center;gap:.65em;max-width:100%;min-height:44px;padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));border:2px solid var(--b-border,var(--kb-border,transparent));border-radius:var(--b-radius,var(--kb-radius,8px));background:var(--b-bg,var(--kb-bg,var(--kb-fill,var(--ink,#203b34))));color:var(--b-fg,var(--kb-fg,var(--kb-ink,var(--paper,#f6f7f4))));font-family:var(--kb-font,inherit);font-size:var(--kb-font-size,14px);font-weight:var(--kb-weight,600);letter-spacing:var(--kb-tracking,0);text-transform:var(--kb-transform,none);line-height:1.6;text-align:center;text-decoration:none;overflow-wrap:anywhere;box-sizing:border-box}
|
||||
.cms-button:hover,.cms-button:focus-visible,.cms-button.is-hover{background:var(--b-hover-bg,var(--kb-hover-bg,var(--kb-hover-fill,var(--ink,#203b34))));color:var(--b-hover-fg,var(--kb-hover-fg,var(--kb-hover-ink,var(--paper,#f6f7f4))));text-decoration:none}
|
||||
.cms-button:focus-visible{outline:3px solid currentColor;outline-offset:4px}
|
||||
.cms-button svg{width:1.15em;height:1.15em;flex:none}
|
||||
.cms-button:where(.b-desktop-shape-square){--b-radius:0px}.cms-button:where(.b-desktop-shape-rounded){--b-radius:8px}.cms-button:where(.b-desktop-shape-pill){--b-radius:999px}
|
||||
.cms-button:where(.b-desktop-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-desktop-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-desktop-size-large){--b-py:19px;--b-px:30px}
|
||||
.cms-button:where(.b-desktop-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
|
||||
.cms-button:where(.b-desktop-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-desktop-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-desktop-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
|
||||
/* Responsive variants */
|
||||
@media(max-width:1024px){
|
||||
.cms-button:where(.b-tablet-shape-square){--b-radius:0px}.cms-button:where(.b-tablet-shape-rounded){--b-radius:8px}.cms-button:where(.b-tablet-shape-pill){--b-radius:999px}
|
||||
.cms-button:where(.b-tablet-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-tablet-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-tablet-size-large){--b-py:19px;--b-px:30px}
|
||||
.cms-button:where(.b-tablet-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
|
||||
.cms-button:where(.b-tablet-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-tablet-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-tablet-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
|
||||
}
|
||||
@media(max-width:767px){
|
||||
.cms-button:where(.b-mobile-shape-square){--b-radius:0px}.cms-button:where(.b-mobile-shape-rounded){--b-radius:8px}.cms-button:where(.b-mobile-shape-pill){--b-radius:999px}
|
||||
.cms-button:where(.b-mobile-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-mobile-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-mobile-size-large){--b-py:19px;--b-px:30px}
|
||||
.cms-button:where(.b-mobile-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
|
||||
.cms-button:where(.b-mobile-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-mobile-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-mobile-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden}
|
||||
.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-trigger:hover{background:#f5f7f3}
|
||||
.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
|
||||
.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px}
|
||||
.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit}
|
||||
.color-description{display:grid;gap:3px;flex:1;min-width:0}
|
||||
.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
|
||||
.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere}
|
||||
.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c}
|
||||
.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)}
|
||||
.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8}
|
||||
.color-choices[hidden]{display:none}
|
||||
.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258}
|
||||
.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin}
|
||||
.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer}
|
||||
.color-option:hover{background:#eef3ea;border-color:#718575}
|
||||
.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8}
|
||||
.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426}
|
||||
.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.color-custom{display:flex;align-items:center;gap:6px}
|
||||
.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff}
|
||||
.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px}
|
||||
.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0}
|
||||
.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer}
|
||||
.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none}
|
||||
@@ -0,0 +1,125 @@
|
||||
/** A compact palette picker. Palette references stay references when saved. */
|
||||
let sequence = 0;
|
||||
const node = (tag, className, text) => {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
};
|
||||
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
||||
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete }) {
|
||||
const wrap = node('div', 'color-control');
|
||||
const trigger = node('button', 'color-trigger');
|
||||
trigger.type = 'button'; trigger.id = id;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.setAttribute('aria-controls', `${id}-choices`);
|
||||
const sample = node('span', 'color-sample'); sample.setAttribute('aria-hidden', 'true');
|
||||
const ink = node('span', 'color-sample-ink'); sample.append(ink);
|
||||
const description = node('span', 'color-description');
|
||||
const name = node('span', 'color-name'), detail = node('span', 'color-detail');
|
||||
description.append(name, detail);
|
||||
const arrow = node('span', 'color-chevron', '⌄'); arrow.setAttribute('aria-hidden', 'true');
|
||||
trigger.append(sample, description, arrow);
|
||||
|
||||
const choices = node('div', 'color-choices'); choices.id = `${id}-choices`; choices.hidden = true;
|
||||
const heading = node('p', 'color-heading', 'Website-Farben');
|
||||
const palette = node('div', 'color-palette');
|
||||
palette.setAttribute('role', 'group'); palette.setAttribute('aria-label', `Website-Farben für ${label}`);
|
||||
const buttons = [];
|
||||
let current = value, editing = false, mergeKey = `color-${++sequence}`;
|
||||
const entryFor = raw => colors.find(color => `@${color.key}` === raw);
|
||||
const resolve = raw => hexPattern.test(raw) ? raw : entryFor(raw)?.value || '';
|
||||
const restoreFocus = () => document.getElementById(id)?.focus();
|
||||
const choose = raw => { current = raw; sync(); onChange(raw); restoreFocus(); };
|
||||
for (const color of colors) {
|
||||
const button = node('button', 'color-option'); button.type = 'button';
|
||||
button.dataset.color = `@${color.key}`;
|
||||
button.title = `${color.name} · ${color.value}`;
|
||||
button.setAttribute('aria-label', button.title);
|
||||
const swatch = node('span', 'color-option-swatch'); swatch.style.backgroundColor = color.value;
|
||||
swatch.setAttribute('aria-hidden', 'true');
|
||||
button.append(swatch, node('span', 'color-option-name', color.name));
|
||||
button.addEventListener('click', () => choose(`@${color.key}`));
|
||||
buttons.push(button); palette.append(button);
|
||||
}
|
||||
if (colors.length) choices.append(heading, palette);
|
||||
|
||||
const customLabel = node('label', 'color-heading', 'Eigene Farbe'); customLabel.htmlFor = `${id}-hex`;
|
||||
const custom = node('div', 'color-custom');
|
||||
const picker = node('input', 'color-native'); picker.type = 'color';
|
||||
picker.setAttribute('aria-label', `Eigene Farbe für ${label}`);
|
||||
const text = node('input', 'color-text'); text.type = 'text'; text.id = `${id}-hex`;
|
||||
text.autocomplete = 'off'; text.spellcheck = false; text.placeholder = '#RRGGBB';
|
||||
text.setAttribute('aria-label', `Farbwert für ${label}`);
|
||||
const accept = node('button', 'color-accept', '✓'); accept.type = 'button';
|
||||
accept.setAttribute('aria-label', `Farbwert für ${label} übernehmen`); accept.title = 'Farbwert übernehmen';
|
||||
custom.append(picker, text, accept);
|
||||
const error = node('p', 'field-error'); error.id = `${id}-error`; error.hidden = true;
|
||||
error.setAttribute('role', 'status'); text.setAttribute('aria-describedby', error.id);
|
||||
const clear = node('button', 'color-inherit', 'Geerbten Wert verwenden'); clear.type = 'button';
|
||||
clear.addEventListener('click', () => choose(''));
|
||||
choices.append(customLabel, custom, error, clear);
|
||||
wrap.append(trigger, choices);
|
||||
|
||||
function sync() {
|
||||
const shown = current || inheritedValue, entry = entryFor(shown), hex = resolve(shown);
|
||||
ink.style.backgroundColor = hex || 'transparent';
|
||||
name.textContent = entry?.name || (hex ? 'Eigene Farbe' : current ? 'Unbekannte Palettenfarbe' : 'Übernehmen');
|
||||
detail.textContent = [!current ? 'Geerbt' : entry ? 'Palette' : '', hex || (current || 'Kein eigener Farbwert')].filter(Boolean).join(' · ');
|
||||
trigger.setAttribute('aria-label', `${label}: ${name.textContent}, ${detail.textContent}. Farbe auswählen`);
|
||||
picker.value = hex ? hex.slice(0, 7) : '#ffffff';
|
||||
text.value = hex || current || '';
|
||||
for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.color === shown));
|
||||
clear.disabled = current === '';
|
||||
}
|
||||
const finish = () => {
|
||||
if (!editing) return;
|
||||
editing = false;
|
||||
onComplete();
|
||||
};
|
||||
trigger.addEventListener('click', () => {
|
||||
if (!choices.hidden) {
|
||||
choices.hidden = true; trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
} else {
|
||||
choices.hidden = false; trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
// A native picker emits many input events during one gesture. Keep its DOM and one undo entry.
|
||||
picker.addEventListener('input', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
editing = true; sync(); onChange(current, mergeKey);
|
||||
});
|
||||
picker.addEventListener('change', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
sync(); onChange(current, editing ? mergeKey : undefined);
|
||||
if (editing) finish();
|
||||
restoreFocus();
|
||||
});
|
||||
const applyText = () => {
|
||||
let raw = text.value.trim();
|
||||
if (/^#[0-9a-f]{3}$/i.test(raw)) raw = '#' + [...raw.slice(1)].map(char => char + char).join('');
|
||||
if (raw && !hexPattern.test(raw) && !entryFor(raw)) {
|
||||
text.setAttribute('aria-invalid', 'true'); error.hidden = false;
|
||||
error.textContent = 'Hexfarbe wie #1a2b3c oder eine vorhandene Palettenfarbe eingeben.';
|
||||
text.focus(); return;
|
||||
}
|
||||
choose(raw);
|
||||
};
|
||||
text.addEventListener('input', () => { text.removeAttribute('aria-invalid'); error.hidden = true; });
|
||||
text.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') { event.preventDefault(); applyText(); }
|
||||
});
|
||||
accept.addEventListener('click', applyText);
|
||||
wrap.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || choices.hidden) return;
|
||||
event.preventDefault(); event.stopPropagation(); choices.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
});
|
||||
// Commit after leaving the native picker even on browsers that omit its change event.
|
||||
wrap.addEventListener('focusout', () => setTimeout(() => {
|
||||
if (wrap.isConnected && !wrap.contains(document.activeElement)) finish();
|
||||
}, 0));
|
||||
sync();
|
||||
return wrap;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
export const GROUPS = {
|
||||
text: 'Typografie',
|
||||
@@ -204,31 +205,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const colorControl = (id, value, apply) => {
|
||||
const wrap = element('div', 'color-control');
|
||||
const picker = document.createElement('input');
|
||||
picker.type = 'color';
|
||||
picker.id = id;
|
||||
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
|
||||
const text = document.createElement('input');
|
||||
text.type = 'text';
|
||||
text.className = 'color-text';
|
||||
text.value = value ?? '';
|
||||
text.placeholder = '#1a2b3c';
|
||||
text.setAttribute('aria-label', 'Farbwert oder Palettenname');
|
||||
const palette = document.createElement('select');
|
||||
palette.className = 'color-palette';
|
||||
palette.setAttribute('aria-label', 'Farbe aus der Palette');
|
||||
palette.append(new Option('Palette …', ''));
|
||||
for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`));
|
||||
palette.value = (value ?? '').startsWith('@') ? value : '';
|
||||
|
||||
picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); });
|
||||
text.addEventListener('change', () => apply(text.value.trim()));
|
||||
palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } });
|
||||
wrap.append(picker, text, palette);
|
||||
return wrap;
|
||||
};
|
||||
const colorControl = (id, value, apply, label, inheritedValue = '') => createColorControl({
|
||||
id, value, label, inheritedValue, colors: schema.colors, onChange: apply,
|
||||
onComplete: () => {
|
||||
const focusedId = document.activeElement?.id;
|
||||
onChange();
|
||||
if (focusedId) document.getElementById(focusedId)?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||||
const wrap = element('div', 'length-control');
|
||||
@@ -539,7 +523,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
body.append(sideHead, sideControl(key, block, deviceName, property));
|
||||
continue;
|
||||
}
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply, property.label, current.value || '');
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
|
||||
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
@@ -552,9 +536,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
if (schema.hoverable.includes(key)) {
|
||||
const hoverId = `${id}-hover`;
|
||||
const hoverValue = hoverStyle(block, deviceName, key);
|
||||
let hoverInherited = current.value || '';
|
||||
for (const name of Object.keys(devices)) {
|
||||
if (name === deviceName) break;
|
||||
hoverInherited = hoverStyle(block, name, key) ?? hoverInherited;
|
||||
}
|
||||
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
|
||||
const hoverControl = property.type === 'color'
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply)
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply, `${property.label} beim Überfahren`, hoverInherited)
|
||||
: property.type === 'number'
|
||||
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
|
||||
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
|
||||
|
||||
@@ -148,6 +148,8 @@ export function insertionTarget(document, selected, type) {
|
||||
}
|
||||
const probe = { type };
|
||||
let item = locate(document.blocks, selected);
|
||||
// Content belongs to a chosen entry. Do not silently place it outside the accordion.
|
||||
if (item?.block.type === 'accordion' && type !== 'accordionitem') return null;
|
||||
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
|
||||
while (item) {
|
||||
const parentId = item.parent?.id ?? null;
|
||||
@@ -169,6 +171,7 @@ export function duplicateBlock(document, selected) {
|
||||
const copy = clone(target.block);
|
||||
const rekey = block => {
|
||||
block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`;
|
||||
delete block.anchor;
|
||||
if (block.type === 'heading' && block.props.level === 1) block.props.level = 2;
|
||||
(block.children || []).forEach(rekey);
|
||||
};
|
||||
@@ -252,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 = 20;
|
||||
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}
|
||||
@@ -110,6 +111,9 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}
|
||||
.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}
|
||||
.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}
|
||||
.accordion-entry-list{display:grid;gap:7px;margin:12px 0}
|
||||
.accordion-entry-choice{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:#f7f9f3;color:var(--ink);font-size:12px;line-height:1.5;text-align:left;overflow-wrap:anywhere}
|
||||
.accordion-entry-choice:hover{border-color:var(--accent);background:#edf3e7}
|
||||
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}
|
||||
.design-reset{margin-top:24px;white-space:normal;width:100%}
|
||||
.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}
|
||||
@@ -131,15 +135,11 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
|
||||
.design-row{margin-top:14px}
|
||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.design-row-head label{margin:0;font-size:11px}
|
||||
.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0}
|
||||
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
||||
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
||||
.color-control{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center}
|
||||
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
|
||||
.color-control .color-text{font-size:12px;min-height:38px}
|
||||
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
|
||||
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
|
||||
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
||||
.length-control.is-inherited input{color:var(--muted)}
|
||||
|
||||
+104
-10
@@ -1,12 +1,14 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
import { createCanvasOverlay } from './canvas-overlay.js';
|
||||
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'];
|
||||
@@ -30,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 = () => {};
|
||||
@@ -101,7 +107,7 @@ function controls() {
|
||||
function renderTree() {
|
||||
const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null;
|
||||
const query = $('#tree-search').value.trim().toLocaleLowerCase('de');
|
||||
const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
|
||||
const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''} ${block.props?.title || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
|
||||
const build = blocks => {
|
||||
const list = document.createElement('ul'); list.className = 'structure-list';
|
||||
for (const block of blocks) {
|
||||
@@ -119,10 +125,17 @@ function renderTree() {
|
||||
button.className = 'tree-item'; button.type = 'button'; button.draggable = true;
|
||||
button.dataset.blockId = block.id;
|
||||
button.setAttribute('aria-pressed', String(block.id === selected));
|
||||
button.setAttribute('aria-label', `${blockLabel(block)}: ${block.props?.text || block.props?.alt || blockLabel(block)}`);
|
||||
const displayText = block.props?.text || block.props?.alt || block.props?.title || blockLabel(block);
|
||||
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText}`);
|
||||
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 = block.props?.text || block.props?.alt || blockLabel(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);
|
||||
}
|
||||
@@ -175,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;
|
||||
@@ -201,7 +227,11 @@ function renderProperties() {
|
||||
history.change(doc => {
|
||||
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
||||
if (!current) return;
|
||||
if (options.layout) (current.layout ??= {})[key] = next;
|
||||
if (options.anchor) {
|
||||
const anchor = String(next).trim().replace(/^#/, '');
|
||||
if (anchor) { current.anchor = anchor; upgradeDocument(doc, 21); } else delete current.anchor;
|
||||
}
|
||||
else if (options.layout) (current.layout ??= {})[key] = next;
|
||||
else if (selected) {
|
||||
(current.props ??= {})[key] = next;
|
||||
// Decorative means "no text": an image loses its alternative text, an icon its label.
|
||||
@@ -211,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 });
|
||||
@@ -247,6 +278,7 @@ function renderProperties() {
|
||||
panel.append(label, rich.element, help);
|
||||
}
|
||||
if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
||||
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
||||
if (INLINE_TEXT[block.type]) {
|
||||
const inline = document.createElement('button');
|
||||
inline.className = 'secondary full-width';
|
||||
@@ -290,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') {
|
||||
@@ -323,12 +356,32 @@ function renderProperties() {
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
const hint = document.createElement('p'); hint.className = 'field-help';
|
||||
hint.textContent = 'Wähle einen Eintrag, um seinen Titel und Inhalt zu bearbeiten. Texte, Bilder und weitere Elemente gehören in den jeweiligen Eintrag.';
|
||||
const entries = document.createElement('div'); entries.className = 'accordion-entry-list';
|
||||
for (const [index, entry] of (block.children ?? []).entries()) {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.className = 'accordion-entry-choice';
|
||||
button.textContent = `${index + 1}. ${entry.props?.title || 'Ohne Titel'}`;
|
||||
button.setAttribute('aria-label', `Eintrag ${index + 1}: ${entry.props?.title || 'Ohne Titel'} bearbeiten`);
|
||||
button.addEventListener('click', () => { propertyPanel = 'content'; choose(entry.id); });
|
||||
entries.append(button);
|
||||
}
|
||||
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
|
||||
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
|
||||
panel.append(hint, entries, add);
|
||||
}
|
||||
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt eingeben. Buttons und Menülinks mit #kontakt springen dann hierher; von anderen Seiten mit /seitenadresse#kontakt.' });
|
||||
if (block.type === 'accordionitem') {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
|
||||
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
|
||||
add.textContent = '+ Inhalt hinzufügen';
|
||||
add.addEventListener('click', () => beginContainerInsert(block.id));
|
||||
panel.append(add);
|
||||
}
|
||||
if (block.type === 'legal') {
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' });
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
|
||||
}
|
||||
if (block.type === 'postnav') {
|
||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||
@@ -479,6 +532,7 @@ function renderImageWidth(block, panel) {
|
||||
}
|
||||
|
||||
function renderDesign(block, panel) {
|
||||
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
||||
if (designSchema && designPanel) {
|
||||
designPanel(panel);
|
||||
} else {
|
||||
@@ -716,20 +770,26 @@ 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();
|
||||
const id = insert.dataset.editorTarget;
|
||||
if (!locate(history.document.blocks, id)) return;
|
||||
choose(id, false);
|
||||
if (insert.hasAttribute('data-editor-row')) {
|
||||
if (insert.hasAttribute('data-editor-add-entry')) {
|
||||
addAccordionEntry(id);
|
||||
} else if (insert.hasAttribute('data-editor-row')) {
|
||||
setPendingInsert(null);
|
||||
selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0));
|
||||
propertyPanel = 'content'; changed();
|
||||
} else if (locate(history.document.blocks, id).block.type === 'row') {
|
||||
selected = history.change(content => insertBlockAt(content, 'column', id, 0));
|
||||
changed();
|
||||
} else setPendingInsert({ parentId: id, index: 0 });
|
||||
} else beginContainerInsert(id);
|
||||
return;
|
||||
}
|
||||
if (inlineEdit) return;
|
||||
@@ -857,9 +917,35 @@ function addElement(type) {
|
||||
selected = history.change(doc => at
|
||||
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
||||
: insertBlock(doc, type, selected));
|
||||
if (type === 'accordion') selected = locate(history.document.blocks, selected)?.block.children?.[0]?.id ?? selected;
|
||||
if (['accordion', 'accordionitem'].includes(type)) propertyPanel = 'content';
|
||||
changed();
|
||||
}
|
||||
|
||||
function buttonControls(block, panel) {
|
||||
renderButtonControls(panel, { block, device, schema: designSchema, apply: (values, focusId) => {
|
||||
history.change(doc => setButtonDesign(doc, block.id, device, values)); changed();
|
||||
document.getElementById(focusId)?.focus({ preventScroll: true });
|
||||
} });
|
||||
}
|
||||
|
||||
function beginContainerInsert(id) {
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (!block || busy) return;
|
||||
propertyPanel = 'content';
|
||||
choose(id, false);
|
||||
setPendingInsert({ parentId: id, index: block.children?.length ?? 0 });
|
||||
}
|
||||
|
||||
function addAccordionEntry(id) {
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (busy || block?.type !== 'accordion') return;
|
||||
setPendingInsert(null);
|
||||
selected = history.change(doc => insertBlockAt(doc, 'accordionitem', id, block.children?.length ?? 0));
|
||||
collapsed.delete(id); propertyPanel = 'content'; changed();
|
||||
$('#property-title')?.focus();
|
||||
}
|
||||
|
||||
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
|
||||
function setPendingInsert(target) {
|
||||
pendingInsert = target;
|
||||
@@ -945,7 +1031,11 @@ function updateLibrary() {
|
||||
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
||||
: insertionTarget(history.document, selected, type);
|
||||
button.disabled = !target;
|
||||
if (!target) { button.title = whyNot(type); continue; }
|
||||
if (!target) {
|
||||
button.title = locate(history.document.blocks, selected)?.block.type === 'accordion'
|
||||
? 'Wähle zuerst einen Akkordeon-Eintrag, um darin Inhalte einzufügen.' : whyNot(type);
|
||||
continue;
|
||||
}
|
||||
const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null;
|
||||
button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`;
|
||||
}
|
||||
@@ -1092,7 +1182,11 @@ overlay = createCanvasOverlay({
|
||||
const id = history.change(doc => payload.columns
|
||||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||||
: insertBlockAt(doc, payload.type, plan.parentId, plan.index));
|
||||
if (id) { selected = id; changed(); }
|
||||
if (id) {
|
||||
selected = payload.type === 'accordion' ? locate(history.document.blocks, id)?.block.children?.[0]?.id ?? id : id;
|
||||
if (['accordion', 'accordionitem'].includes(payload.type)) propertyPanel = 'content';
|
||||
changed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
|
||||
|
||||
@@ -7,6 +7,16 @@ const error = form.querySelector('#menu-error');
|
||||
const history = [];
|
||||
let serial = list.children.length, pendingPage = null, busy = false;
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
|
||||
const iconNames = { app: 'App', web: 'Web', design: 'Gestaltung', network: 'Netzwerk', hardware: 'Computer', shop: 'Shop', support: 'Support', shield: 'Sicherheit', clock: 'Uhr', star: 'Stern', phone: 'Telefon', mail: 'E-Mail', pin: 'Standort', check: 'Haken' };
|
||||
const menuIcons = JSON.parse(form.dataset.icons || '[]');
|
||||
const chrome = () => {
|
||||
const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = '';
|
||||
handle.title = 'Ziehen · Pfeiltasten ändern Reihenfolge und Ebene';
|
||||
handle.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg>';
|
||||
const chevron = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); chevron.setAttribute('class','menu-chevron'); chevron.setAttribute('viewBox','0 0 24 24'); chevron.setAttribute('width','20'); chevron.setAttribute('height','20'); chevron.setAttribute('aria-hidden','true');
|
||||
chevron.innerHTML = '<path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2"/>';
|
||||
return { handle, chevron };
|
||||
};
|
||||
const rows = () => [...list.children].map(row => {
|
||||
const data = { level: Number(row.dataset.level) };
|
||||
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.type === 'checkbox' ? (input.checked ? '1' : '0') : input.value; });
|
||||
@@ -28,6 +38,11 @@ function refresh() {
|
||||
const picker = row.querySelector('.picker-value'); if (picker) picker.name = `items[${index}][pageId]`;
|
||||
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
|
||||
row.querySelector('.menu-entry-title').textContent = name;
|
||||
const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value;
|
||||
const graphic = icon && document.getElementById(`menu-icon-${icon}`)?.content.cloneNode(true);
|
||||
if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic);
|
||||
row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`);
|
||||
row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; });
|
||||
row.querySelector('[data-menu-action="up"]').disabled = index === 0;
|
||||
row.querySelector('[data-menu-action="down"]').disabled = index === list.children.length - 1;
|
||||
row.querySelector('[data-menu-action="indent"]').disabled = index === 0 || row.dataset.level === '2' || list.children[index + 1]?.dataset.level === '2';
|
||||
@@ -58,7 +73,8 @@ function createRow(data) {
|
||||
};
|
||||
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
|
||||
const details = el('details'); const summary = el('summary');
|
||||
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'muted', data.type === 'page' ? 'Inhalt' : 'Eigener Link'));
|
||||
const { handle, chevron } = chrome();
|
||||
summary.append(handle, el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
|
||||
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details);
|
||||
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
|
||||
if (data.type === 'page') {
|
||||
@@ -72,6 +88,12 @@ function createRow(data) {
|
||||
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
|
||||
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
|
||||
for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
|
||||
const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`;
|
||||
for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value));
|
||||
select.value = data[key] || (key === 'iconPosition' ? 'before' : '');
|
||||
const caption = el('label', '', label); caption.htmlFor = select.id; body.append(caption, select);
|
||||
}
|
||||
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
|
||||
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
|
||||
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
|
||||
@@ -140,6 +162,46 @@ list.addEventListener('click', event => {
|
||||
commit(before); if (row.isConnected) row.querySelector('summary').focus();
|
||||
});
|
||||
form.querySelector('#menu-undo').addEventListener('click', () => { const previous = history.pop(); if (previous) restore(previous); });
|
||||
// Pointer capture supports mouse, pen and touch. A parent travels with its children.
|
||||
let drag = null;
|
||||
list.addEventListener('click', event => { if (event.target.closest('[data-menu-drag]')) { event.preventDefault(); event.stopPropagation(); } });
|
||||
list.addEventListener('keydown', event => {
|
||||
const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy) return;
|
||||
const action = { ArrowUp: 'up', ArrowDown: 'down', ArrowRight: 'indent', ArrowLeft: 'outdent' }[event.key];
|
||||
if (action) { event.preventDefault(); const row = handle.closest('.menu-entry'); row.querySelector(`[data-menu-action="${action}"]`).click(); handle.focus(); }
|
||||
});
|
||||
list.addEventListener('pointerdown', event => {
|
||||
const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy || event.button !== 0) return;
|
||||
event.preventDefault(); handle.focus(); handle.setPointerCapture(event.pointerId);
|
||||
const row = handle.closest('.menu-entry'), entries = [...list.children], index = entries.indexOf(row), bundle = [row];
|
||||
if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]);
|
||||
drag = { handle, row, bundle, before: state(), x: event.clientX, y: event.clientY, pointer: event.pointerId, active: false, level: row.dataset.level };
|
||||
});
|
||||
list.addEventListener('pointermove', event => {
|
||||
if (!drag || event.pointerId !== drag.pointer) return;
|
||||
if (!drag.active && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 6) return;
|
||||
drag.active = true; drag.bundle.forEach(row => row.classList.add('menu-dragging'));
|
||||
const others = [...list.children].filter(row => !drag.bundle.includes(row));
|
||||
let target = others.find(row => event.clientY < row.getBoundingClientRect().top + row.getBoundingClientRect().height / 2) || null;
|
||||
// A main entry never splits the children of another main entry.
|
||||
if ((drag.bundle.length > 1 || drag.level === '1') && target?.dataset.level === '2') {
|
||||
let index = others.indexOf(target); while (index > 0 && others[index].dataset.level === '2') index--; target = others[index];
|
||||
}
|
||||
drag.bundle.forEach(row => list.insertBefore(row, target));
|
||||
if (drag.bundle.length === 1) drag.row.dataset.level = event.clientX - drag.x > 36 ? '2' : event.clientX - drag.x < -36 ? '1' : drag.level;
|
||||
if (list.firstElementChild) list.firstElementChild.dataset.level = '1';
|
||||
refresh();
|
||||
if (event.clientY > window.innerHeight - 65) window.scrollBy(0, 18); else if (event.clientY < 65) window.scrollBy(0, -18);
|
||||
});
|
||||
function endDrag(cancel = false) {
|
||||
if (!drag) return;
|
||||
const previous = drag; drag = null;
|
||||
previous.bundle.forEach(row => row.classList.remove('menu-dragging'));
|
||||
if (previous.handle.hasPointerCapture(previous.pointer)) previous.handle.releasePointerCapture(previous.pointer);
|
||||
if (cancel) restore(previous.before); else if (previous.active) { commit(previous.before); previous.handle.focus(); status.textContent = 'Reihenfolge geändert · noch nicht gespeichert'; }
|
||||
}
|
||||
list.addEventListener('pointerup', () => endDrag()); list.addEventListener('pointercancel', () => endDrag(true));
|
||||
document.addEventListener('keydown', event => { if (event.key === 'Escape' && drag) { event.preventDefault(); endDrag(true); } });
|
||||
window.addEventListener('beforeunload', event => { if (dirty()) { event.preventDefault(); event.returnValue = ''; } });
|
||||
form.noValidate = true;
|
||||
form.addEventListener('submit', async event => {
|
||||
@@ -147,13 +209,13 @@ form.addEventListener('submit', async event => {
|
||||
const invalid = form.querySelector(':invalid');
|
||||
if (invalid) { const details = invalid.closest('details'); if (details) details.open = true; invalid.reportValidity(); return; }
|
||||
refresh(); const body = new FormData(form); const snapshot = key(state());
|
||||
busy = true; error.hidden = true; form.querySelectorAll('input,button').forEach(node => { node.disabled = true; }); refresh();
|
||||
busy = true; error.hidden = true; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = true; }); refresh();
|
||||
try {
|
||||
const response = await fetch(form.action, { method: 'POST', body, headers: { Accept: 'application/json' }, credentials: 'same-origin' });
|
||||
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Die Änderungen bleiben hier erhalten. Bitte erneut anmelden.');
|
||||
const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Das Menü konnte nicht gespeichert werden.');
|
||||
form.elements.expectedVersion.value = result.version; saved = snapshot;
|
||||
} catch (failure) { error.textContent = failure.message || 'Verbindung unterbrochen. Die Änderungen bleiben hier erhalten.'; error.hidden = false; }
|
||||
finally { busy = false; form.querySelectorAll('input,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); }
|
||||
finally { busy = false; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); }
|
||||
});
|
||||
refresh(); last = state(); saved = key(last);
|
||||
+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();
|
||||
@@ -5,22 +5,28 @@ export function decorateContainers(preview, content) {
|
||||
const visit = (blocks, parent = null) => {
|
||||
blocks.forEach((block, index) => {
|
||||
const node = preview.getElementById(block.id);
|
||||
if (node && ['section', 'row', 'column'].includes(block.type)) {
|
||||
// Editing must expose the body without changing the visitor's saved open/closed state.
|
||||
if (node && block.type === 'accordionitem') node.open = true;
|
||||
if (node && ['section', 'row', 'column', 'accordion', 'accordionitem'].includes(block.type)) {
|
||||
const label = block.type === 'column' && parent?.type === 'row'
|
||||
? `Spalte ${index + 1} von ${blocks.length}` : blockLabel(block);
|
||||
? `Spalte ${index + 1} von ${blocks.length}` : block.type === 'accordionitem'
|
||||
? `Eintrag „${block.props?.title || 'Ohne Titel'}“` : blockLabel(block);
|
||||
node.classList.add('editor-container');
|
||||
node.dataset.editorLabel = label;
|
||||
if (!block.children?.length) {
|
||||
node.classList.add('editor-empty-container');
|
||||
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner') : node;
|
||||
if (!block.children?.length || ['accordion', 'accordionitem'].includes(block.type)) {
|
||||
const compact = Boolean(block.children?.length);
|
||||
if (!compact) node.classList.add('editor-empty-container');
|
||||
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner')
|
||||
: block.type === 'accordionitem' ? node.querySelector(':scope > .accordion-body') : node;
|
||||
if (!host) return;
|
||||
const empty = preview.createElement('div');
|
||||
empty.className = 'editor-empty';
|
||||
empty.className = compact ? 'editor-empty editor-add-content' : 'editor-empty';
|
||||
const name = preview.createElement('span');
|
||||
name.className = 'editor-empty-name'; name.textContent = label;
|
||||
const hint = preview.createElement('span');
|
||||
hint.className = 'editor-empty-hint';
|
||||
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen' : 'Element hierher ziehen oder hinzufügen';
|
||||
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen'
|
||||
: block.type === 'accordion' ? 'Eintrag hinzufügen und darin Inhalte gestalten' : 'Element hierher ziehen oder hinzufügen';
|
||||
const actions = preview.createElement('div'); actions.className = 'editor-empty-actions';
|
||||
const add = (text, attribute, value, accessibleName) => {
|
||||
const button = preview.createElement('button');
|
||||
@@ -31,10 +37,15 @@ export function decorateContainers(preview, content) {
|
||||
actions.append(button);
|
||||
};
|
||||
add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`);
|
||||
if (block.type === 'accordion') {
|
||||
actions.replaceChildren();
|
||||
add('+ Eintrag hinzufügen', 'data-editor-add-entry', '', 'Akkordeon: Eintrag hinzufügen');
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`);
|
||||
}
|
||||
empty.append(name, hint, actions);
|
||||
if (!compact) empty.append(name, hint);
|
||||
empty.append(actions);
|
||||
host.append(empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,6 @@
|
||||
.editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px}
|
||||
.editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px}
|
||||
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
|
||||
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
|
||||
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
|
||||
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
+22
-7
@@ -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}
|
||||
@@ -162,12 +163,6 @@ body.consent-open{padding-bottom:260px}
|
||||
.site-footer .block-column{border:0;border-radius:0;padding:0}
|
||||
.site-header .block-text,
|
||||
.site-footer .block-text{margin:0}
|
||||
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
|
||||
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
|
||||
.legal-bar a{color:var(--accent);text-decoration:none}
|
||||
.legal-bar a:hover{text-decoration:underline}
|
||||
|
||||
/* An image takes the rounding of its figure. Without this a radius on an image block rounded the
|
||||
figure and left the picture square - which looked like the setting did nothing. */
|
||||
.block-image img, .block-gallery img, .block-logos img { border-radius: inherit }
|
||||
@@ -338,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
|
||||
@@ -379,3 +392,5 @@ body.consent-open .scroll-top{display:none}
|
||||
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
|
||||
.zoom-prev{left:8px}.zoom-next{right:8px}
|
||||
}
|
||||
|
||||
.has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none}
|
||||
+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,39 @@
|
||||
import { buttonSample } from './button-design.js';
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
const root = document.querySelector('#button-defaults');
|
||||
if (root) {
|
||||
const schema = JSON.parse(root.dataset.schema), form = root.closest('form');
|
||||
const read = () => Object.fromEntries([...root.querySelectorAll('[name^="buttons["]')].map(input => [input.name.slice(8, -1), input.value]));
|
||||
const update = () => {
|
||||
// Unsaved palette edits are reflected immediately in all samples.
|
||||
schema.colors = [...form.querySelectorAll('.theme-table tbody tr')].flatMap(row => {
|
||||
const key = row.querySelector('[name$="[key]"]'), value = row.querySelector('[name$="[value]"]');
|
||||
return key?.value && value ? [{ key: key.value, value: value.value }] : [];
|
||||
});
|
||||
const values = read(), previews = root.querySelector('.button-state-previews'); previews.replaceChildren();
|
||||
for (const [label, hover] of [['Normal', false], ['Beim Überfahren', true]]) {
|
||||
const state = document.createElement('div'); state.className = 'button-state';
|
||||
const title = document.createElement('span'); title.className = 'field-help'; title.textContent = label;
|
||||
state.append(title, buttonSample('Mehr erfahren', values, schema, hover)); previews.append(state);
|
||||
}
|
||||
root.querySelectorAll('[data-button-preset]').forEach((choice, index) => {
|
||||
const preset = schema.buttonPresets[index]; choice.setAttribute('aria-pressed', String(preset.shape === values.shape && preset.appearance === values.appearance));
|
||||
choice.querySelector('.cms-button')?.remove();
|
||||
const sample = buttonSample('Button', { ...values, ...preset, size: 'small' }, schema); sample.setAttribute('aria-hidden', 'true'); choice.prepend(sample);
|
||||
});
|
||||
};
|
||||
for (const input of root.querySelectorAll('[data-button-color]')) {
|
||||
const label = root.querySelector(`label[for="${input.id}"]`).textContent;
|
||||
const originalId = input.id; input.type = 'hidden'; input.id += '-value';
|
||||
const control = createColorControl({ id: originalId, label, value: input.value, colors: schema.colors, onChange: value => { input.value = value; update(); } });
|
||||
control.querySelector('.color-inherit').hidden = true;
|
||||
input.after(control);
|
||||
}
|
||||
root.querySelectorAll('[data-button-preset]').forEach((choice, index) => choice.addEventListener('click', () => {
|
||||
const preset = schema.buttonPresets[index];
|
||||
for (const key of ['shape', 'appearance']) root.querySelector(`[name="buttons[${key}]"]`).value = preset[key];
|
||||
update();
|
||||
}));
|
||||
form.addEventListener('input', update); form.addEventListener('change', update); update();
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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/accordion'; 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.blocks.unshift({ id: 'qa-accordion', type: 'accordion', children: [
|
||||
{ id: 'qa-entry', type: 'accordionitem', props: { title: 'Was ist enthalten?', open: false }, children: [] },
|
||||
{ id: 'qa-open', type: 'accordionitem', props: { title: 'Schon geöffnet', open: true }, children: [{ id: 'qa-existing', type: 'text', props: { text: '<p>Vorhandener Inhalt bleibt.</p>' } }] },
|
||||
] }, { id: 'qa-empty-accordion', type: 'accordion', children: [] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const frame = page.frameLocator('#preview');
|
||||
const ready = async () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
const select = async id => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator('[data-panel="content"]').click(); };
|
||||
await ready();
|
||||
await frame.locator('#qa-entry > summary').click();
|
||||
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
|
||||
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
|
||||
assert.equal(await page.locator('#save').isDisabled(), true, 'Revealing the editor body must not change the document');
|
||||
await frame.locator('#qa-entry [data-editor-insert]').focus();
|
||||
await frame.locator('#qa-entry [data-editor-insert]').press('Enter');
|
||||
assert.match(await page.locator('#insert-hint').innerText(), /Was ist enthalten/);
|
||||
await page.locator('#library [data-add="text"]').click();
|
||||
await ready(); await frame.locator('#qa-entry .block-text').waitFor();
|
||||
const textId = await frame.locator('#qa-entry .block-text').getAttribute('id');
|
||||
await page.getByLabel('Text', { exact: true }).fill('Text direkt im Akkordeon.'); await ready();
|
||||
assert.equal(await frame.locator('#qa-entry .block-text').innerText(), 'Text direkt im Akkordeon.');
|
||||
await page.locator('#undo').click(); await page.locator('#undo').click(); await ready();
|
||||
assert.equal(await frame.locator(`#${textId}`).count(), 0);
|
||||
await page.locator('#redo').click(); await page.locator('#redo').click(); await ready();
|
||||
assert.equal(await frame.locator(`#qa-entry #${textId}`).count(), 1);
|
||||
checks.push('closed entries reveal editable bodies and empty insert areas without changing public state; text insertion, editing and undo/redo');
|
||||
|
||||
await select('qa-accordion');
|
||||
assert.equal(await page.locator('#library [data-add="text"]').isDisabled(), true, 'Group selection must not put text outside the accordion');
|
||||
await page.getByRole('button', { name: 'Eintrag 1: Was ist enthalten? bearbeiten', exact: true }).click();
|
||||
await page.getByRole('button', { name: '+ Inhalt hinzufügen', exact: true }).click();
|
||||
await page.locator('#library [data-add="button"]').click(); await ready();
|
||||
const firstButtonId = await frame.locator('#qa-entry a.button').getAttribute('id');
|
||||
assert.equal(await frame.locator('#qa-entry .accordion-body > [id]').last().getAttribute('id'), firstButtonId, 'Add content appends after existing content');
|
||||
await select('qa-open');
|
||||
await page.locator('#library [data-add="button"]').dragTo(frame.locator('#qa-open .accordion-body'), { targetPosition: { x: 40, y: 20 } });
|
||||
await ready(); await frame.locator('#qa-open a.button').waitFor();
|
||||
const draggedId = await frame.locator('#qa-open a.button').getAttribute('id');
|
||||
await page.locator('#undo').click(); await ready(); assert.equal(await frame.locator(`#${draggedId}`).count(), 0);
|
||||
await page.locator('#redo').click(); await ready(); assert.equal(await frame.locator(`#qa-open #${draggedId}`).count(), 1);
|
||||
checks.push('entry selection in inspector, append content and drag/drop into an open body with undo/redo');
|
||||
|
||||
await frame.locator('#qa-accordion > .editor-add-content [data-editor-add-entry]').click();
|
||||
await page.getByLabel('Titel', { exact: true }).fill('Ein neuer Eintrag'); await ready();
|
||||
const newEntryId = await frame.locator('#qa-accordion > details').last().getAttribute('id');
|
||||
assert.equal(await frame.locator('#qa-accordion > details').count(), 3);
|
||||
assert.equal(await frame.locator(`#${newEntryId}`).evaluate(el => el.open), true);
|
||||
await select('qa-empty-accordion');
|
||||
await page.getByRole('button', { name: '+ Eintrag hinzufügen', exact: true }).click(); await ready();
|
||||
assert.equal(await frame.locator('#qa-empty-accordion > details').count(), 1);
|
||||
await page.getByLabel('Titel', { exact: true }).fill('Erster Eintrag'); await ready();
|
||||
await page.locator('#tree-search').fill('Ein neuer Eintrag');
|
||||
assert.equal(await page.locator(`#structure [data-block-id="${newEntryId}"]`).count(), 1);
|
||||
await page.locator('#tree-search').fill('');
|
||||
await select('qa-entry'); await page.locator('[data-panel="design"]').click();
|
||||
await page.locator('#library [data-add="accordion"]').click(); await ready();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Akkordeon-Eintrag');
|
||||
assert.equal(await page.getByLabel('Titel', { exact: true }).inputValue(), 'Eine Frage');
|
||||
await page.locator('#undo').click(); await ready();
|
||||
checks.push('add entries in canvas and inspector, recover empty accordion, search by title, new accordion starts with its editable entry selected');
|
||||
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await ready(); await select('qa-entry');
|
||||
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
|
||||
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
|
||||
const saved = await (await context.request.get(api)).json();
|
||||
const accordion = saved.document.blocks.find(b => b.id === 'qa-accordion');
|
||||
assert.equal(accordion.children[0].props.open, false); assert.equal(accordion.children[1].props.open, true);
|
||||
assert.deepEqual(accordion.children[0].children.map(b => b.id), [textId, firstButtonId]);
|
||||
assert.ok(!JSON.stringify(saved.document).includes('editor-empty'));
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false });
|
||||
const visitor = await plain.newPage(); await visitor.goto(origin);
|
||||
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
|
||||
assert.equal(await visitor.locator('#qa-open').evaluate(el => el.open), true);
|
||||
assert.equal(await visitor.locator('[data-editor-insert],[data-editor-add-entry],.editor-empty').count(), 0);
|
||||
await visitor.locator('#qa-entry > summary').click();
|
||||
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), true);
|
||||
assert.equal(await visitor.locator(`#qa-entry #${textId}`).isVisible(), true);
|
||||
await visitor.locator('#qa-entry > summary').press('Enter');
|
||||
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
|
||||
await plain.close();
|
||||
checks.push('save/reload/publication keep children and original open flags; published accordion toggles without JavaScript and contains no editing aids');
|
||||
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 1050 }); await select('qa-entry'); await ready();
|
||||
await page.locator('[data-viewport="mobile"]').click(); await ready();
|
||||
await frame.locator('#qa-entry').scrollIntoViewIfNeeded();
|
||||
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze();
|
||||
assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
await page.screenshot({ path: `${report}/editor-${width}.png`, fullPage: true });
|
||||
}
|
||||
checks.push('desktop/mobile editor passes axe and layout checks with editable accordion preview');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Accordion 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(); }
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
const file = new URL('../public/assets/buttons.css', import.meta.url);
|
||||
const source = (await readFile(file, 'utf8')).split('/* Responsive variants */')[0].trimEnd();
|
||||
const rules = source.split('\n').filter(line => line.includes('b-desktop-')).join('\n');
|
||||
await writeFile(file, `${source}\n/* Responsive variants */\n@media(max-width:1024px){\n${rules.replaceAll('b-desktop-', 'b-tablet-')}\n}\n@media(max-width:767px){\n${rules.replaceAll('b-desktop-', 'b-mobile-')}\n}\n`);
|
||||
@@ -0,0 +1,139 @@
|
||||
import { chromium } from '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/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++) {
|
||||
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.blocks.unshift({ id: 'qa-buttons-section', type: 'section', children: [
|
||||
{ id: 'qa-button-default', type: 'button', props: { text: 'Kontakt aufnehmen', href: '#kontakt' } },
|
||||
{ id: 'qa-button', type: 'button', props: { text: 'Mehr erfahren', href: '/kontakt' }, style: { desktop: { margin: { left: '12px' }, fontSize: '17px' } } }
|
||||
] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(origin + '/admin/theme');
|
||||
await page.locator('#buttons-fontSize').fill('18');
|
||||
await page.getByRole('button', { name: 'Standardbutton Rahmen · Pille', exact: true }).click();
|
||||
assert.equal(await page.locator('#buttons-shape').inputValue(), 'pill');
|
||||
assert.equal(await page.locator('#buttons-appearance').inputValue(), 'outline');
|
||||
assert.equal(await page.locator('#button-defaults .button-state .cms-button').first().evaluate(el => getComputedStyle(el).borderRadius), '999px');
|
||||
await page.locator('#button-defaults').screenshot({ path: `${report}/theme.png` });
|
||||
await page.getByRole('button', { name: 'Design speichern', exact: true }).click();
|
||||
assert.equal(await page.locator('#buttons-shape').inputValue(), 'pill');
|
||||
assert.equal(await page.locator('#buttons-fontSize').inputValue(), '18');
|
||||
checks.push('global typography, visual presets, normal/hover preview and actual form save including empty palette rows');
|
||||
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await page.locator('#structure [data-block-id="qa-button"]').click();
|
||||
await page.getByRole('button', { name: 'Button-Vorlage Abgerundet', exact: true }).click();
|
||||
const waitVariant = async (id, radius, background) => page.waitForFunction(({id,radius,background}) => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, el = doc?.getElementById(id);
|
||||
if (!el) return false;
|
||||
const s = doc.defaultView.getComputedStyle(el);
|
||||
return s.borderRadius === radius && (background === undefined || (s.backgroundColor === 'rgba(0, 0, 0, 0)') === background);
|
||||
}, { id, radius, background });
|
||||
await waitVariant('qa-button', '8px', false);
|
||||
await page.locator('#undo').click(); await waitVariant('qa-button', '999px', true);
|
||||
await page.locator('#redo').click(); await waitVariant('qa-button', '8px', false);
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await page.getByRole('button', { name: 'Button-Vorlage Rahmen · eckig', exact: true }).click();
|
||||
await waitVariant('qa-button', '0px', true);
|
||||
await page.locator('[data-viewport="mobile"]').click();
|
||||
await page.getByRole('button', { name: 'Button-Vorlage Pille', exact: true }).click();
|
||||
await waitVariant('qa-button', '999px', false);
|
||||
await page.locator('#button-reset').click(); await waitVariant('qa-button', '0px', true);
|
||||
await page.locator('#undo').click(); await waitVariant('qa-button', '999px', false);
|
||||
await page.locator('[data-viewport="desktop"]').click(); await waitVariant('qa-button', '8px', false);
|
||||
await page.locator('.button-designer').screenshot({ path: `${report}/editor-controls.png` });
|
||||
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-button"]').click(); await waitVariant('qa-button', '8px', false);
|
||||
const stored = (await (await context.request.get(api)).json()).document;
|
||||
assert.equal(stored.schemaVersion, 21);
|
||||
assert.deepEqual(stored.blocks[0].children[1].props, { text: 'Mehr erfahren', href: '/kontakt' });
|
||||
assert.equal(stored.blocks[0].children[1].style.desktop.margin.left, '12px');
|
||||
assert.equal(stored.blocks[0].children[1].style.desktop.fontSize, '17px');
|
||||
checks.push('preset selection, device inheritance/reset, undo/redo, save/reload, preserved content and unrelated styling');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
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 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.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,147 @@
|
||||
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/color-picker'; 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();
|
||||
const schema = await (await context.request.get(origin + '/admin/api/design')).json();
|
||||
const deep = schema.colors.find(c => c.key === 'deep');
|
||||
const accent = schema.colors.find(c => c.key === 'accent');
|
||||
assert.ok(deep && accent);
|
||||
data.document.blocks.unshift({ id: 'qa-color', type: 'section', style: { desktop: {
|
||||
backgroundColor: '@deep', color: '#ffffff', gradientFrom: '#11223344', gradientTo: '#55667788',
|
||||
hover: { backgroundColor: '@accent' }, padding: { top: '40px', bottom: '40px' },
|
||||
} }, children: [{ id: 'qa-color-text', type: 'text', props: { text: '<p>Farben direkt im Blick</p>' } }] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const select = async () => {
|
||||
await page.locator('#structure [data-block-id="qa-color"]').click();
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
const group = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Hintergrund$/ }) });
|
||||
if (!(await group.getAttribute('open'))) {
|
||||
if (!(await group.evaluate(el => el.open))) await group.locator('summary').click();
|
||||
}
|
||||
};
|
||||
await select();
|
||||
const trigger = (key = 'backgroundColor') => page.locator(`#style-${key}`);
|
||||
const choices = (key = 'backgroundColor') => page.locator(`#style-${key}-choices`);
|
||||
const sample = async (hex, key = 'backgroundColor') => {
|
||||
await page.waitForFunction(({ hex, key }) => document.querySelector(`#style-${key} .color-detail`)?.textContent.includes(hex), { hex, key });
|
||||
assert.ok(await trigger(key).locator('.color-sample-ink').evaluate(el => el.style.backgroundColor !== '' && el.style.backgroundColor !== 'transparent'));
|
||||
};
|
||||
const choose = async (token, key = 'backgroundColor') => { await trigger(key).click(); await choices(key).locator(`[data-color="${token}"]`).click(); };
|
||||
const frameColor = async hex => {
|
||||
const rgb = `rgb(${[1,3,5].map(i => parseInt(hex.slice(i,i+2),16)).join(', ')})`;
|
||||
await page.waitForFunction(rgb => {
|
||||
const doc = document.querySelector('#preview').contentDocument, block = doc?.getElementById('qa-color');
|
||||
return block && doc.defaultView.getComputedStyle(block).backgroundColor === rgb;
|
||||
}, rgb);
|
||||
};
|
||||
await sample(deep.value); assert.match(await trigger().innerText(), new RegExp(deep.name));
|
||||
await trigger().focus(); await page.keyboard.press('Enter');
|
||||
assert.equal(await trigger().getAttribute('aria-expanded'), 'true');
|
||||
await page.keyboard.press('Tab'); assert.equal(await choices().locator('[data-color]').first().evaluate(el => el === document.activeElement), true);
|
||||
await page.keyboard.press('Escape'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true);
|
||||
await choose('@accent'); await sample(accent.value); await frameColor(accent.value);
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await page.locator('#redo').click(); await sample(accent.value);
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
checks.push('palette name and real token color, keyboard open/tab/escape, selection, canvas, undo and redo');
|
||||
|
||||
await page.locator('[data-viewport="tablet"]').click(); await sample(deep.value);
|
||||
assert.match(await trigger().innerText(), /Geerbt/);
|
||||
await sample(accent.value, 'backgroundColor-hover');
|
||||
await choose('@accent'); await page.locator('[data-viewport="mobile"]').click(); await sample(accent.value);
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await page.getByRole('button', { name: 'Hintergrundfarbe zurücksetzen', exact: true }).click(); await sample(deep.value);
|
||||
await page.locator('[data-viewport="desktop"]').click();
|
||||
checks.push('desktop/tablet/mobile inheritance including hover colors and reset');
|
||||
|
||||
await trigger().click();
|
||||
await choices().locator('.color-native').evaluate(el => {
|
||||
window.colorPickerNode = el;
|
||||
el.focus();
|
||||
for (const value of ['#112233', '#223344', '#334455']) { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
});
|
||||
assert.equal(await choices().locator('.color-native').evaluate(el => el === window.colorPickerNode), true);
|
||||
await frameColor('#334455');
|
||||
await choices().locator('.color-native').dispatchEvent('change');
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await trigger().click();
|
||||
await choices().locator('.color-native').evaluate(el => { el.value = '#445566'; el.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await sample('#445566'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true);
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await trigger().click();
|
||||
const hex = page.locator('#style-backgroundColor-hex');
|
||||
await hex.fill('ungueltig'); await hex.press('Enter'); assert.equal(await hex.getAttribute('aria-invalid'), 'true');
|
||||
await sample(deep.value);
|
||||
await hex.fill('#abc'); await hex.press('Enter'); await sample('#aabbcc'); await frameColor('#aabbcc');
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await trigger('gradientFrom').click();
|
||||
await choices('gradientFrom').locator('.color-native').evaluate(el => { el.value = '#224466'; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await sample('#22446644', 'gradientFrom');
|
||||
await page.locator('#undo').click(); await sample('#11223344', 'gradientFrom');
|
||||
checks.push('native live color changes retain DOM and form one undo step; invalid input refused; short hex expanded; gradient alpha preserved');
|
||||
|
||||
await choose('@accent');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await select(); await sample(accent.value);
|
||||
const saved = await (await context.request.get(api)).json();
|
||||
assert.equal(saved.document.blocks[0].style.desktop.backgroundColor, '@accent');
|
||||
assert.equal(saved.document.blocks[0].style.desktop.gradientFrom, '#11223344');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const visitor = await context.newPage(); await visitor.goto(origin);
|
||||
assert.equal(await visitor.locator('#qa-color').evaluate(el => getComputedStyle(el).backgroundColor), `rgb(${[1,3,5].map(i => parseInt(accent.value.slice(i,i+2),16)).join(', ')})`);
|
||||
await visitor.close();
|
||||
checks.push('save, reload and publication preserve palette references and alpha values');
|
||||
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 1050 });
|
||||
if (await trigger().getAttribute('aria-expanded') === 'false') await trigger().click();
|
||||
await choices().scrollIntoViewIfNeeded();
|
||||
await trigger().evaluate(el => {
|
||||
const panel = el.closest('.right-panel');
|
||||
panel.scrollTop += el.getBoundingClientRect().top - panel.getBoundingClientRect().top - 16;
|
||||
});
|
||||
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze();
|
||||
assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
assert.equal(await choices().evaluate(el => el.scrollWidth <= el.clientWidth), true);
|
||||
await page.locator('.right-panel').screenshot({ path: `${report}/palette-${width}.png` });
|
||||
}
|
||||
checks.push('open palette and editor pass axe and fit desktop/mobile without horizontal overflow');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Color picker 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(); }
|
||||
@@ -487,7 +487,8 @@ try {
|
||||
await frameValue('#headline', 'fontSize', '72px');
|
||||
|
||||
// Colours: a palette reference resolves to the theme variable, a hex value is taken as it is.
|
||||
await page.locator('#style-color').locator('xpath=following-sibling::select').selectOption('@link');
|
||||
await page.locator('#style-color').click();
|
||||
await page.locator('#style-color-choices [data-color="@link"]').click();
|
||||
await frameValue('#headline', 'color', 'rgb(164, 81, 15)');
|
||||
const contrast = page.locator('.contrast-note');
|
||||
await contrast.waitFor();
|
||||
@@ -572,9 +573,10 @@ try {
|
||||
// The background group is collapsed by default, and the readable field next to the swatch is
|
||||
// what a person types into.
|
||||
await openGroup('Hintergrund');
|
||||
const hoverColor = page.locator('#style-backgroundColor-hover').locator('xpath=following-sibling::input[contains(@class,"color-text")]');
|
||||
await page.locator('#style-backgroundColor-hover').click();
|
||||
const hoverColor = page.locator('#style-backgroundColor-hover-hex');
|
||||
await hoverColor.fill('#112233');
|
||||
await hoverColor.blur();
|
||||
await hoverColor.press('Enter');
|
||||
// The preview carries its stylesheet as template text, applied by the editor as a constructed
|
||||
// stylesheet; the hover rule has to arrive through that path, not as a style element.
|
||||
await page.waitForFunction(() => {
|
||||
@@ -857,7 +859,7 @@ try {
|
||||
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
|
||||
await visitorPage.locator('iframe').waitFor({ state: 'attached' });
|
||||
assert.equal(await visitorPage.locator('.consent-bar').count(), 0, 'A decision is a decision.');
|
||||
assert.equal(await visitorPage.locator('a[href="/einwilligung"]').count(), 1, 'Revoking has to stay reachable.');
|
||||
assert.equal(await visitorPage.locator('.legal-bar').count(), 0, 'Footer links are placed by the author (since 1.8.1).');
|
||||
await visitorPage.screenshot({ path: 'reports/editor/consent-granted.png', fullPage: false });
|
||||
// And the way back: the consent page can refuse again, which blocks the embed once more.
|
||||
await visitorPage.goto(origin + '/einwilligung');
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { chromium } from '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';
|
||||
import { mediaProps, upgradeDocument } from '../public/assets/editor-model.js';
|
||||
|
||||
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/image-alignment'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
|
||||
// Geometry of the visible image as well as its figure: centering an empty, full-width
|
||||
// figure alone must not pass while the logo inside it stays at the left edge.
|
||||
const geometry = figure => {
|
||||
const img = figure.querySelector('img');
|
||||
const parent = figure.parentElement, style = getComputedStyle(parent);
|
||||
const p = parent.getBoundingClientRect(), f = figure.getBoundingClientRect(), i = img.getBoundingClientRect();
|
||||
const left = p.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft);
|
||||
const width = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
const top = p.top + parseFloat(style.borderTopWidth) + parseFloat(style.paddingTop);
|
||||
const height = parent.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
||||
return { left: f.left - left, right: left + width - f.right, width: f.width, available: width,
|
||||
center: i.left + i.width / 2 - left - width / 2, innerCenter: i.left + i.width / 2 - f.left - f.width / 2,
|
||||
verticalCenter: f.top + f.height / 2 - top - height / 2, imageWidth: i.width, imageHeight: i.height,
|
||||
align: getComputedStyle(figure).alignSelf };
|
||||
};
|
||||
const closeTo = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 2, `${message}: ${actual} vs ${expected}`);
|
||||
|
||||
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();
|
||||
upgradeDocument(data.document, 20);
|
||||
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
|
||||
const chosen = media.find(item => /teamviewer/i.test(item.title)); assert.ok(chosen);
|
||||
const original = mediaProps(chosen); original.alt ||= chosen.title;
|
||||
const small = { ...original, width: 180, height: Math.round(180 * original.height / original.width) };
|
||||
const image = (id, style, props = original) => ({ id, type: 'image', props: structuredClone(props), style });
|
||||
const centered = { desktop: { width: '40%', alignSelf: 'center' } };
|
||||
const container = (id, style, child) => ({ id, type: 'column', style, children: [child] });
|
||||
const section = { id: 'qa-alignment-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px', padding: { left: '20px', right: '20px' } } }, children: [
|
||||
container('qa-flow', { desktop: { padding: { left: '18px', right: '18px' } } }, image('qa-center', { desktop: { width: '50%' } })),
|
||||
image('qa-natural', { desktop: { alignSelf: 'center' } }, small),
|
||||
image('qa-linked', { desktop: { width: '60%', alignSelf: 'center', margin: { left: '11px', right: '7px' } }, tablet: { alignSelf: 'flex-end' }, mobile: { alignSelf: 'auto' } }, { ...small, click: 'link', href: '/kontakt', newTab: true }),
|
||||
container('qa-grid', { desktop: { display: 'grid', gridColumns: '1', height: '160px' } }, image('qa-grid-image', centered)),
|
||||
container('qa-flex-column', { desktop: { display: 'flex', flexDirection: 'column', height: '160px' } }, image('qa-column-image', centered)),
|
||||
container('qa-flex-row', { desktop: { display: 'flex', height: '160px' } }, image('qa-row-image', centered)),
|
||||
container('qa-responsive-parent', { desktop: { display: 'flex', height: '160px' }, tablet: { flexDirection: 'column' }, mobile: { display: 'block' } }, image('qa-responsive-image', centered)),
|
||||
image('qa-zoom', { desktop: { maxWidth: '300px', alignSelf: 'center' } }, { ...small, click: 'vergroessern' }),
|
||||
] };
|
||||
data.document.blocks.unshift(section);
|
||||
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}`);
|
||||
const select = async () => {
|
||||
await page.locator('#structure [data-block-id="qa-center"]').click(); await page.locator('[data-panel="design"]').click();
|
||||
const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Layout$/ }) });
|
||||
if (await group.getAttribute('open') === null) await group.locator('summary').click();
|
||||
};
|
||||
const preview = () => page.frameLocator('#preview');
|
||||
const aligned = async value => {
|
||||
await page.waitForFunction(value => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, figure = doc?.getElementById('qa-center');
|
||||
return figure && doc.defaultView.getComputedStyle(figure).alignSelf === value;
|
||||
}, value);
|
||||
return preview().locator('#qa-center').evaluate(geometry);
|
||||
};
|
||||
await select(); closeTo((await aligned('auto')).left, 0, 'Initial left edge');
|
||||
await page.locator('#style-alignSelf').selectOption('center');
|
||||
closeTo((await aligned('center')).center, 0, 'Center in normal column');
|
||||
await page.locator('#undo').click(); closeTo((await aligned('auto')).left, 0, 'Undo centering');
|
||||
await page.locator('#redo').click(); closeTo((await aligned('center')).center, 0, 'Redo centering');
|
||||
await page.locator('#style-alignSelf').focus(); await page.locator('#style-alignSelf').press('ArrowDown');
|
||||
closeTo((await aligned('flex-end')).right, 0, 'Keyboard end alignment');
|
||||
await page.locator('#style-alignSelf').selectOption('flex-start'); closeTo((await aligned('flex-start')).left, 0, 'Start alignment');
|
||||
await page.locator('#style-alignSelf').selectOption('center'); await aligned('center');
|
||||
checks.push('actual Design control: center, start, end, keyboard, undo and redo in a normal padded column');
|
||||
|
||||
const checkLayout = async (root, device) => {
|
||||
for (const id of ['qa-center', 'qa-natural', 'qa-grid-image', 'qa-column-image', 'qa-zoom']) {
|
||||
closeTo((await root.locator(`#${id}`).evaluate(geometry)).center, 0, `${device} ${id} centered`);
|
||||
}
|
||||
const normal = await root.locator('#qa-center').evaluate(geometry);
|
||||
closeTo(normal.width / normal.available * 100, 50, 'Percentage width preserved');
|
||||
const natural = await root.locator('#qa-natural').evaluate(geometry);
|
||||
closeTo(natural.imageWidth, 180, 'Natural logo is not stretched');
|
||||
const row = await root.locator('#qa-row-image').evaluate(geometry);
|
||||
closeTo(row.left, 0, 'Flex row keeps its main-axis position'); closeTo(row.verticalCenter, 0, 'Flex row retains vertical centering');
|
||||
const changing = await root.locator('#qa-responsive-image').evaluate(geometry);
|
||||
closeTo(device === 'desktop' ? changing.left : changing.center, 0, 'Responsive parent layout');
|
||||
const linked = await root.locator('#qa-linked').evaluate(geometry);
|
||||
if (device === 'desktop') closeTo(linked.center, 0, 'Linked image centered');
|
||||
if (device === 'tablet') { closeTo(linked.right, 0, 'Tablet figure at end'); const img = await root.locator('#qa-linked img').boundingBox(); const fig = await root.locator('#qa-linked').boundingBox(); closeTo(img.x + img.width, fig.x + fig.width, 'Small linked image at end'); }
|
||||
if (device === 'mobile') { closeTo(linked.left, 11, 'Auto restores authored margin'); closeTo(linked.innerCenter, -(linked.width - linked.imageWidth) / 2, 'Auto clears inherited internal alignment'); }
|
||||
assert.equal(await root.locator('#qa-linked a').getAttribute('target'), '_blank');
|
||||
closeTo(normal.imageWidth / normal.imageHeight, original.width / original.height, 'Aspect ratio preserved');
|
||||
};
|
||||
for (const device of ['desktop', 'tablet', 'mobile']) {
|
||||
await page.locator(`[data-viewport="${device}"]`).click(); await aligned('center');
|
||||
await checkLayout(preview(), device);
|
||||
}
|
||||
await page.locator('[data-viewport="desktop"]').click(); await aligned('center');
|
||||
await preview().locator('#qa-flow').screenshot({ path: `${report}/centered-column.png` });
|
||||
await page.locator('#style-alignSelf').scrollIntoViewIfNeeded(); await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true });
|
||||
checks.push('preview: natural, percentage and maximum widths; links/zoom; grid and flex axes; responsive parent and auto reset');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await select(); closeTo((await aligned('center')).center, 0, 'Reload preserves alignment');
|
||||
const stored = await (await context.request.get(api)).json();
|
||||
const block = stored.document.blocks[0].children[0].children[0];
|
||||
assert.deepEqual(block.props, original); assert.deepEqual(block.style, { desktop: { width: '50%', alignSelf: 'center' } });
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
|
||||
for (const [width, device] of [[1600, 'desktop'], [900, 'tablet'], [390, 'mobile']]) {
|
||||
await publicPage.setViewportSize({ width, height: 1000 }); await publicPage.goto(origin);
|
||||
await checkLayout(publicPage, device);
|
||||
await publicPage.locator('#qa-alignment-section').screenshot({ path: `${report}/published-${device}.png` });
|
||||
}
|
||||
await plain.close(); checks.push('save/reload, unchanged media metadata, published desktop/tablet/mobile with JavaScript disabled');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Image alignment 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(); }
|
||||
@@ -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,92 @@
|
||||
import { chromium } from '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/navigation-ux'; 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.blocks.push({ id: 'qa-anchor', type: 'section', children: [{ id: 'qa-anchor-title', type: 'heading', props: { text: 'Kontaktbereich', level: 2 } }] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await page.locator('#structure [data-block-id="qa-anchor"]').click();
|
||||
await page.locator('#property-anchor').fill('#kontakt-check'); await page.locator('#property-anchor').blur();
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
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.blocks.at(-1).anchor, 'kontakt-check'); assert.equal(stored.schemaVersion, 21);
|
||||
checks.push('named jump target created in editor and saved/published with stable block ID');
|
||||
|
||||
await page.goto(origin + '/admin/menus');
|
||||
const rows = page.locator('#menu-items > li');
|
||||
const originalFirst = await rows.first().locator('[data-key="label"]').inputValue();
|
||||
const handle = rows.first().locator('[data-menu-drag]');
|
||||
const start = await handle.boundingBox(), end = await rows.last().boundingBox();
|
||||
await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); await page.mouse.down();
|
||||
await page.mouse.move(start.x + start.width / 2, end.y + end.height - 2, { steps: 12 }); await page.mouse.up();
|
||||
assert.equal(await rows.last().locator('[data-key="label"]').inputValue(), originalFirst);
|
||||
await page.locator('#menu-undo').click(); assert.equal(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst);
|
||||
await rows.first().locator('[data-menu-drag]').focus(); await page.keyboard.press('ArrowDown');
|
||||
assert.notEqual(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst);
|
||||
await page.locator('#menu-undo').click();
|
||||
await rows.first().locator('summary').click();
|
||||
await rows.first().locator('[data-key="icon"]').selectOption('phone');
|
||||
await rows.first().locator('[data-key="iconPosition"]').selectOption('after');
|
||||
assert.equal(await rows.first().locator('.menu-entry-title > svg').count(), 1);
|
||||
await page.getByText('Eigener Link', { exact: true }).first().click();
|
||||
await page.locator('#link-label').fill('Zum Kontaktbereich'); await page.locator('#link-url').fill('#kontakt-check');
|
||||
await page.locator('#menu-add-url').click();
|
||||
await page.getByRole('button', { name: 'Menü speichern', exact: true }).click();
|
||||
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
|
||||
await page.reload();
|
||||
assert.equal(await rows.first().locator('[data-key="icon"]').inputValue(), 'phone');
|
||||
assert.equal(await rows.first().locator('[data-key="iconPosition"]').inputValue(), 'after');
|
||||
await rows.first().locator('summary').click();
|
||||
await page.screenshot({ path: `${report}/menus.png`, fullPage: true });
|
||||
checks.push('pointer drag, keyboard ordering, undo, icon placement, fragment menu link and save/reload');
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
|
||||
await publicPage.goto(origin);
|
||||
assert.ok(await publicPage.locator('.nav-label > svg').count() > 0);
|
||||
await publicPage.getByRole('link', { name: 'Zum Kontaktbereich', exact: true }).first().click();
|
||||
assert.ok(publicPage.url().endsWith('#kontakt-check'));
|
||||
assert.equal(await publicPage.locator('#kontakt-check').count(), 1);
|
||||
assert.ok(await publicPage.evaluate(() => window.scrollY) > 0);
|
||||
await plain.close();
|
||||
execFileSync(php, ['-r', 'require "vendor/autoload.php"; $db=App\\Infrastructure\\ConnectionFactory::create($argv[1]); $db->executeStatement("UPDATE scheduled_task SET last_status = ?, last_message = ? WHERE name = ?", ["ok", "Fassung 99.0.0 ist verfügbar; installiert ist 1.8.4.", "update-check"]);', fixture.databaseUrl], { windowsHide: true });
|
||||
await page.reload();
|
||||
assert.equal(await page.getByRole('link', { name: 'Update 99.0.0 verfügbar. Aktualisierung öffnen.', exact: true }).count(), 1);
|
||||
assert.match(await page.locator('.version-update').getAttribute('href'), /system\?pruefen=1$/);
|
||||
await page.locator('.shell-foot').screenshot({ path: `${report}/update-hint.png` });
|
||||
checks.push('public icon and native anchor navigation without JavaScript; cached update notice by version');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('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(); }
|
||||
@@ -0,0 +1,137 @@
|
||||
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/not-found'; 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 visitor = await context.newPage();
|
||||
visitor.on('pageerror', error => errors.push(error.message));
|
||||
await visitor.goto(origin);
|
||||
const shell = await visitor.evaluate(() => ({
|
||||
header: document.querySelector('.site-header').textContent.replace(/\s+/g, ' ').trim(),
|
||||
footer: document.querySelector('.site-footer').textContent.replace(/\s+/g, ' ').trim(),
|
||||
font: getComputedStyle(document.body).fontFamily,
|
||||
}));
|
||||
const checkError = async (target, heading = 'Seite nicht gefunden', targetPage = visitor) => {
|
||||
const response = await targetPage.goto(origin + target);
|
||||
assert.equal(response.status(), 404);
|
||||
assert.ok(response.headers()['x-robots-tag'].includes('noindex'));
|
||||
assert.ok(response.headers()['content-security-policy'].includes("'sha256-"));
|
||||
assert.equal(await targetPage.locator('h1').innerText(), heading);
|
||||
assert.equal((await targetPage.locator('.site-header').textContent()).replace(/\s+/g, ' ').trim(), shell.header);
|
||||
assert.equal((await targetPage.locator('.site-footer').textContent()).replace(/\s+/g, ' ').trim(), shell.footer);
|
||||
assert.equal(await targetPage.evaluate(() => getComputedStyle(document.body).fontFamily), shell.font);
|
||||
assert.equal(await targetPage.locator('link[rel="canonical"]').count(), 0);
|
||||
assert.equal(await targetPage.locator('meta[name="robots"]').getAttribute('content'), 'noindex, follow');
|
||||
};
|
||||
const audit = async targetPage => {
|
||||
const result = await new AxeBuilder({ page: targetPage }).analyze();
|
||||
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await targetPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
};
|
||||
await checkError('/eine-unbekannte-seite');
|
||||
await audit(visitor); await visitor.screenshot({ path: `${report}/default-desktop.png`, fullPage: true });
|
||||
await checkError('/Alte/Seite.html'); await checkError('/eins/zwei/drei/vier/fuenf');
|
||||
await visitor.setViewportSize({ width: 390, height: 900 });
|
||||
await checkError('/__404-vorschau__'); await audit(visitor);
|
||||
await visitor.screenshot({ path: `${report}/default-mobile.png`, fullPage: true });
|
||||
checks.push('default 404, unusual/deep URLs, correct status/noindex, standard header/footer/fonts, desktop/mobile axe and layout');
|
||||
|
||||
await page.goto(origin + '/admin/settings#not-found');
|
||||
await page.getByRole('button', { name: '404-Vorlage anlegen', exact: true }).click();
|
||||
await page.waitForURL(/\/admin\/pages\/[a-f0-9]{32}$/);
|
||||
const id = new URL(page.url()).pathname.split('/').pop();
|
||||
const selectHeading = async () => {
|
||||
await page.locator('#structure [data-block-id="error-heading"]').click();
|
||||
await page.locator('[data-panel="content"]').click();
|
||||
};
|
||||
await selectHeading(); await page.getByLabel('Text', { exact: true }).fill('Hier bist du falsch abgebogen');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
|
||||
await checkError('/404', 'Hier bist du falsch abgebogen');
|
||||
await audit(visitor); await visitor.screenshot({ path: `${report}/custom-mobile.png`, fullPage: true });
|
||||
await visitor.setViewportSize({ width: 1600, height: 1050 });
|
||||
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen'); await audit(visitor);
|
||||
await visitor.screenshot({ path: `${report}/custom-desktop.png`, fullPage: true });
|
||||
const sitemap = await (await context.request.get(origin + '/sitemap.xml')).text();
|
||||
assert.ok(!sitemap.includes(origin + '/404'));
|
||||
checks.push('create an assigned draft, edit in normal editor, keep draft private, publish immediately, assigned own URL remains 404 and out of sitemap');
|
||||
|
||||
await page.goto(origin + '/admin/settings#not-found');
|
||||
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
|
||||
const clear = () => page.getByRole('button', { name: '404-Seite suchen: Auswahl entfernen', exact: true });
|
||||
const saveAssignment = async () => {
|
||||
await page.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin/settings#not-found');
|
||||
};
|
||||
await clear().click(); await saveAssignment();
|
||||
await page.getByText('Aktiv: Standard-404 im Website-Design.', { exact: true }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad');
|
||||
const assignExisting = async () => {
|
||||
await page.getByLabel('404-Seite suchen', { exact: true }).fill('Seite nicht gefunden');
|
||||
await page.locator('#not-found-picker-results [role="option"]').first().waitFor();
|
||||
await page.getByLabel('404-Seite suchen', { exact: true }).press('ArrowDown');
|
||||
await page.getByLabel('404-Seite suchen', { exact: true }).press('Enter');
|
||||
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
|
||||
await saveAssignment();
|
||||
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
|
||||
};
|
||||
await assignExisting(); await page.reload();
|
||||
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
|
||||
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
|
||||
const stale = await context.newPage(); await stale.goto(origin + '/admin/settings#not-found');
|
||||
await clear().click(); await saveAssignment();
|
||||
await stale.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
|
||||
await stale.getByText('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.', { exact: true }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad'); await stale.close();
|
||||
await assignExisting();
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 1100 }); await page.locator('#not-found').scrollIntoViewIfNeeded();
|
||||
await audit(page); await page.locator('#not-found').screenshot({ path: `${report}/settings-${width}.png` });
|
||||
}
|
||||
checks.push('live search without submitting, keyboard assignment, reload persistence, reset to default and protection against stale settings forms');
|
||||
|
||||
const withoutScripts = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 900 } });
|
||||
const plain = await withoutScripts.newPage();
|
||||
await checkError('/Alte/Seite.html', 'Hier bist du falsch abgebogen', plain);
|
||||
await plain.getByRole('link', { name: 'Zur Startseite', exact: true }).click();
|
||||
await plain.waitForURL(origin + '/'); await withoutScripts.close();
|
||||
const normal = await visitor.goto(origin); assert.equal(normal.status(), 200);
|
||||
assert.equal(await visitor.locator('link[rel="canonical"]').getAttribute('href'), origin + '/');
|
||||
await audit(visitor);
|
||||
checks.push('public 404 and working home link without JavaScript; normal home remains 200 with canonical and passes axe');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Custom 404 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(); }
|
||||
@@ -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(); }
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
|
||||
/** Shared vocabulary for global button defaults and the responsive block variants. */
|
||||
final class ButtonDesign
|
||||
{
|
||||
public const OPTIONS = [
|
||||
'shape' => ['square' => 'Eckig', 'rounded' => 'Abgerundet', 'pill' => 'Pille'],
|
||||
'appearance' => ['solid' => 'Gefüllt', 'outline' => 'Rahmen', 'soft' => 'Dezent', 'link' => 'Textlink'],
|
||||
'size' => ['small' => 'Klein', 'normal' => 'Normal', 'large' => 'Groß'],
|
||||
];
|
||||
|
||||
public const PRESETS = [
|
||||
['name' => 'Eckig', 'shape' => 'square', 'appearance' => 'solid'],
|
||||
['name' => 'Abgerundet', 'shape' => 'rounded', 'appearance' => 'solid'],
|
||||
['name' => 'Pille', 'shape' => 'pill', 'appearance' => 'solid'],
|
||||
['name' => 'Rahmen · eckig', 'shape' => 'square', 'appearance' => 'outline'],
|
||||
['name' => 'Rahmen · rund', 'shape' => 'rounded', 'appearance' => 'outline'],
|
||||
['name' => 'Rahmen · Pille', 'shape' => 'pill', 'appearance' => 'outline'],
|
||||
['name' => 'Dezent', 'shape' => 'rounded', 'appearance' => 'soft'],
|
||||
['name' => 'Textlink', 'shape' => 'square', 'appearance' => 'link'],
|
||||
];
|
||||
|
||||
public static function defaults(array $base): array
|
||||
{
|
||||
return ['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit',
|
||||
'fontSize' => 14, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none',
|
||||
'backgroundColor' => $base['color'], 'color' => $base['backgroundColor'],
|
||||
'hoverBackgroundColor' => $base['color'], 'hoverColor' => $base['backgroundColor']];
|
||||
}
|
||||
|
||||
public static function validate(array $input, array $base, array $colors, array $fonts): array
|
||||
{
|
||||
$value = [...self::defaults($base), ...$input];
|
||||
foreach ([...self::OPTIONS, 'textTransform' => ['none' => '', 'uppercase' => '']] as $key => $options) {
|
||||
if (!is_string($value[$key]) || !isset($options[$value[$key]])) {
|
||||
throw new InvalidPage('Unbekannte Button-Einstellung: '.$key);
|
||||
}
|
||||
}
|
||||
foreach (['fontSize' => [10, 72], 'fontWeight' => [100, 900], 'letterSpacing' => [-1, 8]] as $key => [$min, $max]) {
|
||||
if (!is_numeric($value[$key]) || $value[$key] < $min || $value[$key] > $max) {
|
||||
throw new InvalidPage('Bitte prüfe den Button-Wert: '.$key);
|
||||
}
|
||||
$value[$key] = (float) $value[$key];
|
||||
}
|
||||
if (!in_array((int) $value['fontWeight'], Theme::WEIGHTS, true) || $value['fontWeight'] != (int) $value['fontWeight']) {
|
||||
throw new InvalidPage('Unbekannte Button-Schriftstärke.');
|
||||
}
|
||||
if (!in_array($value['fontFamily'], ['inherit', ...array_column($fonts, 'key')], true)) {
|
||||
throw new InvalidPage('Die Button-Schrift ist nicht vorhanden.');
|
||||
}
|
||||
foreach (['backgroundColor', 'color', 'hoverBackgroundColor', 'hoverColor'] as $key) {
|
||||
if (!is_string($value[$key]) || 1 !== preg_match(StyleSchema::COLOR, $value[$key])
|
||||
|| (str_starts_with($value[$key], '@') && !in_array(substr($value[$key], 1), array_column($colors, 'key'), true))) {
|
||||
throw new InvalidPage('Bitte prüfe die Button-Farbe: '.$key);
|
||||
}
|
||||
}
|
||||
|
||||
return array_intersect_key($value, self::defaults($base));
|
||||
}
|
||||
|
||||
/** Palette references stay live; block variants use these same variables. */
|
||||
public static function css(array $buttons): string
|
||||
{
|
||||
$color = static fn (string $v): string => str_starts_with($v, '@') ? 'var(--k-'.substr($v, 1).')' : $v;
|
||||
$out = [
|
||||
'--kb-fill' => $color($buttons['backgroundColor']), '--kb-ink' => $color($buttons['color']),
|
||||
'--kb-hover-fill' => $color($buttons['hoverBackgroundColor']), '--kb-hover-ink' => $color($buttons['hoverColor']),
|
||||
'--kb-font' => 'inherit' === $buttons['fontFamily'] ? 'inherit' : 'var(--kf-'.$buttons['fontFamily'].')',
|
||||
'--kb-font-size' => $buttons['fontSize'].'px', '--kb-weight' => $buttons['fontWeight'],
|
||||
'--kb-tracking' => $buttons['letterSpacing'].'px', '--kb-transform' => $buttons['textTransform'],
|
||||
'--kb-radius' => ['square' => '0px', 'rounded' => '8px', 'pill' => '999px'][$buttons['shape']],
|
||||
'--kb-py' => ['small' => '10px', 'normal' => '15px', 'large' => '19px'][$buttons['size']],
|
||||
'--kb-px' => ['small' => '16px', 'normal' => '21px', 'large' => '30px'][$buttons['size']],
|
||||
];
|
||||
foreach (self::appearance($buttons['appearance']) as $key => $value) {
|
||||
$out['--kb-'.$key] = $value;
|
||||
}
|
||||
|
||||
return ':root{'.implode(';', array_map(static fn ($key, $value): string => $key.':'.$value, array_keys($out), $out)).'}';
|
||||
}
|
||||
|
||||
public static function appearance(string $name): array
|
||||
{
|
||||
return match ($name) {
|
||||
'outline' => ['bg' => 'transparent', 'fg' => 'var(--kb-fill)', 'border' => 'var(--kb-fill)', 'hover-bg' => 'var(--kb-fill)', 'hover-fg' => 'var(--kb-ink)'],
|
||||
'soft' => ['bg' => 'color-mix(in srgb,var(--kb-fill) 12%,transparent)', 'fg' => 'var(--kb-fill)', 'border' => 'transparent', 'hover-bg' => 'var(--kb-fill)', 'hover-fg' => 'var(--kb-ink)'],
|
||||
'link' => ['bg' => 'transparent', 'fg' => 'var(--kb-fill)', 'border' => 'transparent', 'hover-bg' => 'transparent', 'hover-fg' => 'var(--kb-hover-fill)'],
|
||||
default => ['bg' => 'var(--kb-fill)', 'fg' => 'var(--kb-ink)', 'border' => 'transparent', 'hover-bg' => 'var(--kb-hover-fill)', 'hover-fg' => 'var(--kb-hover-ink)'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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], 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.');
|
||||
@@ -52,6 +52,16 @@ final class DocumentValidator
|
||||
foreach ($document['blocks'] as $block) {
|
||||
$this->block($block, $ids, $headings, 0, $schemaVersion, null);
|
||||
}
|
||||
$anchors = function (array $blocks) use (&$anchors, &$ids): void {
|
||||
foreach ($blocks as $block) {
|
||||
if (isset($block['anchor'])) {
|
||||
$this->require(!isset($ids[$block['anchor']]) && !in_array($block['anchor'], ['main', 'top', 'admin-main'], true), 'Dieses Sprungziel ist bereits belegt. Bitte einen eindeutigen Namen wählen.');
|
||||
$ids[$block['anchor']] = true;
|
||||
}
|
||||
$anchors($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$anchors($document['blocks']);
|
||||
$this->require(
|
||||
$requireHeading ? 1 === $headings : 0 === $headings,
|
||||
$requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.',
|
||||
@@ -65,6 +75,11 @@ final class DocumentValidator
|
||||
$this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && !isset($ids[$id]), 'Block IDs must be stable and unique.');
|
||||
$ids[$id] = true;
|
||||
$type = $block['type'] ?? null;
|
||||
if (isset($block['anchor'])) {
|
||||
$this->require($schemaVersion >= 21 && in_array($type, ['section', 'row', 'column'], true)
|
||||
&& is_string($block['anchor']) && 1 === preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D', $block['anchor']),
|
||||
'Ein Sprungziel braucht einen Bereich und einen Namen wie kontakt (ohne #), Schema v21.');
|
||||
}
|
||||
// From schema v4 on, a namespaced type belongs to a plugin. The core does not know its
|
||||
// property schema, so it keeps the block untouched instead of rejecting or dropping the
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
@@ -96,6 +111,20 @@ 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) {
|
||||
$this->require(isset(StyleSchema::DEVICES[$device]) && is_array($settings), 'Unbekanntes Button-Gerät.');
|
||||
foreach ($settings as $setting => $token) {
|
||||
$this->require(is_string($token) && isset(ButtonDesign::OPTIONS[$setting][$token]), 'Unbekannte Button-Variante.');
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ('effects' === $key && $schemaVersion >= 3) {
|
||||
$this->require(is_array($value), 'Effects must be an object.');
|
||||
$effects = [
|
||||
@@ -231,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.');
|
||||
|
||||
@@ -132,7 +132,7 @@ final class MenuRepository
|
||||
if ('' === trim($label)) {
|
||||
continue;
|
||||
}
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : [])];
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : [])];
|
||||
}
|
||||
|
||||
return $links;
|
||||
@@ -156,6 +156,12 @@ final class MenuRepository
|
||||
if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) {
|
||||
throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
if (isset($item['icon']) && (!is_string($item['icon']) || !in_array($item['icon'], ['', ...DocumentValidator::ICONS], true))) {
|
||||
throw new InvalidMenu('Unbekanntes Menü-Icon.');
|
||||
}
|
||||
if (isset($item['iconPosition']) && !in_array($item['iconPosition'], ['before', 'after'], true)) {
|
||||
throw new InvalidMenu('Ein Icon steht vor oder nach dem Text.');
|
||||
}
|
||||
if ('page' === ($item['type'] ?? null)) {
|
||||
if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) {
|
||||
throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.');
|
||||
@@ -183,8 +189,9 @@ final class MenuRepository
|
||||
$allowed = $allowed || (str_starts_with($url, '/') && !str_starts_with($url, '//'));
|
||||
$allowed = $allowed || 1 === preg_match('/^mailto:[^@\s]+@[^@\s]+$/D', $url);
|
||||
$allowed = $allowed || 1 === preg_match('/^tel:\+?[0-9 ()-]{3,32}$/D', $url);
|
||||
$allowed = $allowed || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D', $url);
|
||||
if (!$allowed) {
|
||||
throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade sowie mailto: und tel:.');
|
||||
throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade, Sprungziele wie #kontakt sowie mailto: und tel:.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/** A normal block page supplies the content; an error response always keeps the site shell. */
|
||||
final class NotFoundPage
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private PageRepository $pages, private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function selected(): ?array
|
||||
{
|
||||
$id = $this->settings->load()['notFoundPageId'];
|
||||
|
||||
return '' === $id ? null : ($this->pages->choices(id: $id)[0] ?? null);
|
||||
}
|
||||
|
||||
public function page(int $status = 404): array
|
||||
{
|
||||
$id = $this->settings->load()['notFoundPageId'];
|
||||
$published = 404 === $status && '' !== $id ? $this->pages->findPublishedById($id) : null;
|
||||
|
||||
return [
|
||||
'id' => 'error-'.$status, 'path' => '', 'document' => self::document($status),
|
||||
...($published ?? []),
|
||||
'locale' => 'de', 'type' => 'page', 'robots' => 'noindex', 'seo_title' => '',
|
||||
'header_area' => null, 'footer_area' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function document(int $status = 404): array
|
||||
{
|
||||
$title = 410 === $status ? 'Diese Seite ist nicht mehr verfügbar.' : 'Seite nicht gefunden';
|
||||
|
||||
return [
|
||||
'schemaVersion' => 10, 'title' => $title, 'description' => 'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.',
|
||||
'blocks' => [[
|
||||
'id' => 'error-section', 'type' => 'section',
|
||||
'style' => ['desktop' => ['textAlign' => 'center', 'minHeight' => '45vh', 'padding' => ['top' => '80px', 'bottom' => '80px']]],
|
||||
'children' => [
|
||||
['id' => 'error-code', 'type' => 'text', 'props' => ['text' => (string) $status]],
|
||||
['id' => 'error-heading', 'type' => 'heading', 'props' => ['text' => $title, 'level' => 1]],
|
||||
['id' => 'error-message', 'type' => 'text', 'props' => ['text' => 'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.']],
|
||||
['id' => 'error-home', 'type' => 'button', 'props' => ['text' => 'Zur Startseite', 'href' => '/']],
|
||||
],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
public function create(string $expectedId): string
|
||||
{
|
||||
return $this->db->transactional(function () use ($expectedId): string {
|
||||
if (null !== $this->selected()) {
|
||||
throw new InvalidPage('Es ist bereits eine 404-Seite zugewiesen. Du kannst sie direkt bearbeiten.');
|
||||
}
|
||||
$slug = '404';
|
||||
for ($suffix = 2; false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', 'de', $slug]); ++$suffix) {
|
||||
$slug = '404-'.$suffix;
|
||||
}
|
||||
$id = $this->pages->createPage($slug, null, 'page', self::document());
|
||||
$this->db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $id]);
|
||||
$this->settings->assignNotFoundPage($id, $expectedId);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
|
||||
@@ -53,6 +54,7 @@ final class SiteSettings
|
||||
'robotsExtra' => '',
|
||||
// Which stored page answers at the site root. Everything else derives from this.
|
||||
'homePath' => 'home',
|
||||
'notFoundPageId' => '',
|
||||
// The consent question. Off by default, because a site that loads nothing from third
|
||||
// parties has nothing to ask about, and a banner without a reason is only in the way.
|
||||
'consentBanner' => false,
|
||||
@@ -96,6 +98,8 @@ final class SiteSettings
|
||||
public function save(array $input): void
|
||||
{
|
||||
$values = self::DEFAULTS;
|
||||
// Assigned separately, so saving company data cannot clear the error page.
|
||||
$values['notFoundPageId'] = $this->load()['notFoundPageId'];
|
||||
foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) {
|
||||
$values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max);
|
||||
}
|
||||
@@ -194,9 +198,13 @@ final class SiteSettings
|
||||
throw new InvalidPage('Die Startseite braucht eine gültige Adresse.');
|
||||
}
|
||||
// A root that points nowhere would answer every visit with 404, so it must exist.
|
||||
if (false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home])) {
|
||||
$homeId = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home]);
|
||||
if (false === $homeId) {
|
||||
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home));
|
||||
}
|
||||
if ($homeId === $values['notFoundPageId']) {
|
||||
throw new InvalidPage('Die 404-Seite kann nicht gleichzeitig die Startseite sein.');
|
||||
}
|
||||
$values['homePath'] = $home;
|
||||
|
||||
$values['consentBanner'] = (bool) ($input['consentBanner'] ?? false);
|
||||
@@ -237,6 +245,41 @@ final class SiteSettings
|
||||
]);
|
||||
}
|
||||
|
||||
/** Change only the 404 assignment, preserving settings saved in other forms. */
|
||||
public function assignNotFoundPage(string $id, string $expectedId): void
|
||||
{
|
||||
$this->db->transactional(function () use ($id, $expectedId): void {
|
||||
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$stored = is_array($stored) ? $stored : [];
|
||||
$current = $stored['notFoundPageId'] ?? '';
|
||||
if ($current !== $expectedId) {
|
||||
throw new StaleWrite('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.');
|
||||
}
|
||||
if ('' !== $id) {
|
||||
$page = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$id, 'default']);
|
||||
if (false === $page || 'page' !== $page['type']) {
|
||||
throw new InvalidPage('Bitte wähle eine vorhandene Seite als 404-Seite.');
|
||||
}
|
||||
if ($page['path'] === ($stored['homePath'] ?? self::DEFAULTS['homePath'])) {
|
||||
throw new InvalidPage('Die Startseite kann nicht gleichzeitig die 404-Seite sein.');
|
||||
}
|
||||
}
|
||||
if ($current === $id) {
|
||||
return;
|
||||
}
|
||||
$stored['notFoundPageId'] = $id;
|
||||
$changed = $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
|
||||
]);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('Die Einstellungen wurden inzwischen geändert. Lade die Seite neu.');
|
||||
}
|
||||
});
|
||||
$this->loaded = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the two mandatory pages are missing, by their settings key.
|
||||
*
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace App\Content;
|
||||
final class StyleBundle
|
||||
{
|
||||
/** Die Reihenfolge ist die Reihenfolge im Kopf der Seite - später gewinnt. */
|
||||
private const SOURCES = ['fonts/fonts.css', 'site.css', 'design.css', 'enhancements.css'];
|
||||
private const SOURCES = ['fonts/fonts.css', 'site.css', 'buttons.css', 'design.css', 'enhancements.css'];
|
||||
|
||||
public function __construct(private string $assetDirectory)
|
||||
{
|
||||
|
||||
@@ -97,12 +97,15 @@ final class StyleCompiler
|
||||
public function compileDocument(array $document): string
|
||||
{
|
||||
$css = '';
|
||||
$walk = function (array $blocks) use (&$walk, &$css): void {
|
||||
$walk = function (array $blocks, array $parent = []) use (&$walk, &$css): void {
|
||||
foreach ($blocks as $block) {
|
||||
if (is_array($block['style'] ?? null) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', (string) ($block['id'] ?? ''))) {
|
||||
$css .= $this->compileBlock((string) $block['id'], $block['style']);
|
||||
if ('image' === ($block['type'] ?? null)) {
|
||||
$css .= $this->imageAlignment((string) $block['id'], $block['style'], $parent);
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
$walk($block['children'] ?? [], $block);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
@@ -110,6 +113,50 @@ final class StyleCompiler
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* In a normal section/column, align-self cannot position a figure. Align its box
|
||||
* and the possibly smaller image inside it, including linked images. A horizontal
|
||||
* flex group must keep align-self's vertical axis without consuming its main-axis
|
||||
* space with automatic margins. Resolve both styles at each breakpoint so a parent
|
||||
* layout change or "auto" on a phone cannot leave desktop alignment behind.
|
||||
*/
|
||||
private function imageAlignment(string $id, array $style, array $parent): string
|
||||
{
|
||||
$css = $previous = '';
|
||||
$values = $group = [];
|
||||
// These block types render children in an unstyled inner wrapper.
|
||||
$wrapped = in_array($parent['type'] ?? '', ['section', 'accordionitem', 'slider'], true);
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$values = array_replace_recursive($values, $style[$device] ?? []);
|
||||
$group = array_replace_recursive($group, $parent['style'][$device] ?? []);
|
||||
if (!isset($values['alignSelf'])) {
|
||||
continue;
|
||||
}
|
||||
$alignment = match ($values['alignSelf']) {
|
||||
'center' => ['auto', 'auto', 'center'],
|
||||
'flex-start' => ['0', 'auto', 'start'],
|
||||
'flex-end' => ['auto', '0', 'end'],
|
||||
default => null,
|
||||
};
|
||||
$horizontalFlex = !$wrapped
|
||||
&& in_array($group['display'] ?? '', ['flex', 'inline-flex'], true)
|
||||
&& in_array($group['flexDirection'] ?? 'row', ['row', 'row-reverse'], true);
|
||||
[$left, $right, $justify] = $horizontalFlex || null === $alignment
|
||||
? [$values['margin']['left'] ?? '0', $values['margin']['right'] ?? '0', 'auto']
|
||||
: $alignment;
|
||||
$rules = sprintf('#%s{margin-left:%s;margin-right:%s;justify-self:%s}#%s img{margin-left:%s;margin-right:%s}',
|
||||
$id, $left, $right, $justify, $id, $alignment[0] ?? '0', $alignment[1] ?? '0');
|
||||
if ($rules !== $previous) {
|
||||
$css .= isset(self::BREAKPOINTS[$device])
|
||||
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules)
|
||||
: $rules;
|
||||
$previous = $rules;
|
||||
}
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
private function compileBlock(string $id, array $style): string
|
||||
{
|
||||
$css = '';
|
||||
|
||||
+13
-4
@@ -62,6 +62,7 @@ final class Theme
|
||||
|
||||
/** Remembered for one request, like the settings; dropped on every write. */
|
||||
private ?array $loaded = null;
|
||||
private bool $buttonsConfigured = false;
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
@@ -70,8 +71,9 @@ final class Theme
|
||||
}
|
||||
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$this->buttonsConfigured = is_array($stored['buttons'] ?? null);
|
||||
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
|
||||
return $this->loaded = [...self::DEFAULTS, 'presets' => []];
|
||||
return $this->loaded = [...self::DEFAULTS, 'presets' => [], 'buttons' => ButtonDesign::defaults(self::DEFAULTS['base'])];
|
||||
}
|
||||
|
||||
return $this->loaded = [
|
||||
@@ -79,10 +81,11 @@ final class Theme
|
||||
'fonts' => $stored['fonts'],
|
||||
'base' => [...self::DEFAULTS['base'], ...$stored['base']],
|
||||
'presets' => is_array($stored['presets'] ?? null) ? $stored['presets'] : [],
|
||||
'buttons' => [...ButtonDesign::defaults([...self::DEFAULTS['base'], ...$stored['base']]), ...($stored['buttons'] ?? [])],
|
||||
];
|
||||
}
|
||||
|
||||
public function save(array $colors, array $fonts, array $base): void
|
||||
public function save(array $colors, array $fonts, array $base, ?array $buttons = null): void
|
||||
{
|
||||
$theme = [
|
||||
'colors' => $this->colors($colors),
|
||||
@@ -91,6 +94,9 @@ final class Theme
|
||||
// Saving the palette must not drop the presets an editor has collected.
|
||||
'presets' => $this->load()['presets'],
|
||||
];
|
||||
if (null !== $buttons || $this->buttonsConfigured) {
|
||||
$theme['buttons'] = ButtonDesign::validate($buttons ?? $this->load()['buttons'], $theme['base'], $theme['colors'], $theme['fonts']);
|
||||
}
|
||||
$keys = array_column($theme['colors'], 'key');
|
||||
foreach (['color', 'backgroundColor', 'linkColor'] as $field) {
|
||||
$value = $theme['base'][$field];
|
||||
@@ -119,7 +125,7 @@ final class Theme
|
||||
foreach ($input as $row) {
|
||||
$key = strtolower(trim((string) ($row['key'] ?? '')));
|
||||
$value = trim((string) ($row['value'] ?? ''));
|
||||
if ('' === $key && '' === $value) {
|
||||
if ('' === $key) {
|
||||
continue;
|
||||
}
|
||||
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,31}$/D', $key) || isset($seen[$key])) {
|
||||
@@ -242,6 +248,9 @@ final class Theme
|
||||
$compiler->validate($style);
|
||||
}
|
||||
$theme = $this->load();
|
||||
if (!$this->buttonsConfigured) {
|
||||
unset($theme['buttons']);
|
||||
}
|
||||
$presets = array_values(array_filter($theme['presets'], static fn (array $p): bool => $p['key'] !== $key));
|
||||
if (null !== $style) {
|
||||
if (count($presets) >= self::MAX_PRESETS) {
|
||||
@@ -317,7 +326,7 @@ final class Theme
|
||||
$colour($base['linkColor']),
|
||||
($base['linkUnderline'] ?? true) ? 'underline' : 'none',
|
||||
$headings,
|
||||
);
|
||||
).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -131,6 +131,9 @@ final class EditorController extends AbstractController
|
||||
'fonts' => $theme['fonts'],
|
||||
'presets' => $theme['presets'],
|
||||
'base' => $theme['base'],
|
||||
'buttons' => $theme['buttons'],
|
||||
'buttonOptions' => \App\Content\ButtonDesign::OPTIONS,
|
||||
'buttonPresets' => \App\Content\ButtonDesign::PRESETS,
|
||||
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ final class MenuController extends AbstractController
|
||||
return $this->render('admin/menus.html.twig', [
|
||||
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
|
||||
'menu' => $menu, 'rows' => $rows,
|
||||
'menuIcons' => \App\Content\DocumentValidator::ICONS,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -77,6 +78,7 @@ final class MenuController extends AbstractController
|
||||
'label' => $item['label'], 'type' => $item['type'], 'level' => $level,
|
||||
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
|
||||
'newTab' => $item['newTab'] ?? false,
|
||||
'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before',
|
||||
];
|
||||
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
||||
}
|
||||
@@ -103,6 +105,7 @@ final class MenuController extends AbstractController
|
||||
'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'),
|
||||
'url' => trim((string) ($row['url'] ?? '')),
|
||||
'newTab' => '1' === (string) ($row['newTab'] ?? ''),
|
||||
'icon' => (string) ($row['icon'] ?? ''), 'iconPosition' => (string) ($row['iconPosition'] ?? 'before'),
|
||||
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
||||
'position' => (int) ($row['position'] ?? 0),
|
||||
];
|
||||
@@ -112,6 +115,10 @@ final class MenuController extends AbstractController
|
||||
$items = [];
|
||||
foreach ($flat as $row) {
|
||||
$item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []];
|
||||
if ('' !== $row['icon']) {
|
||||
$item['icon'] = $row['icon'];
|
||||
$item['iconPosition'] = $row['iconPosition'];
|
||||
}
|
||||
if ($row['newTab']) {
|
||||
$item['newTab'] = true;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
@@ -24,7 +25,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -45,96 +46,106 @@ final class PageController extends AbstractController
|
||||
return $this->redirectToRoute('home', status: 301);
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($page);
|
||||
$needsSlider = $needsMotion = false;
|
||||
if (null !== $page) {
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
}
|
||||
if (null === $page) {
|
||||
// An address that moved or was deliberately removed answers before the 404 does.
|
||||
$redirect = $this->redirects->find($path);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
}
|
||||
$response = $this->render('not-found.html.twig', ['areas' => $areas], new Response(status: null !== $redirect ? 410 : 404));
|
||||
} else {
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
'needsMotion' => $needsMotion,
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'breadcrumbs' => $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $this->structuredData($page),
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
// Rendered before the page content, so the header decides first.
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
|
||||
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
|
||||
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
|
||||
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|
||||
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
|
||||
]);
|
||||
|
||||
return $this->notFound($request, null !== $redirect ? 410 : 404);
|
||||
}
|
||||
if ($page['id'] === $this->settings->load()['notFoundPageId']) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
|
||||
return $this->renderPage($request, $page, $path);
|
||||
}
|
||||
|
||||
public function notFound(Request $request, int $status = 404): Response
|
||||
{
|
||||
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
|
||||
}
|
||||
|
||||
/** Published pages and error content share theme, areas, block rendering and CSP. */
|
||||
private function renderPage(Request $request, array $page, string $path, int $status = 200): Response
|
||||
{
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($status >= 400 ? null : $page);
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
'needsMotion' => $needsMotion,
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $status >= 400 ? [] : $this->structuredData($page),
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
// Rendered before the page content, so the header decides first.
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
], new Response(status: $status));
|
||||
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
$mitTurnstile = $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
}
|
||||
|
||||
if ($status >= 400) {
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||||
}
|
||||
|
||||
@@ -441,7 +452,7 @@ final class PageController extends AbstractController
|
||||
if (null !== $slug) {
|
||||
$term = $this->posts->findTerm($kind, $slug);
|
||||
if (null === $term) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
return $this->notFound($request);
|
||||
}
|
||||
}
|
||||
$page = max(1, $request->query->getInt('seite', 1));
|
||||
@@ -449,7 +460,7 @@ final class PageController extends AbstractController
|
||||
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
|
||||
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
|
||||
if (0 === $result['total']) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
|
||||
@@ -468,10 +479,10 @@ final class PageController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
public function feed(): Response
|
||||
public function feed(Request $request): Response
|
||||
{
|
||||
if (!$this->posts->anyPublished()) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$result = $this->posts->published(1, null, 20);
|
||||
$response = $this->shell($this->render('feed.xml.twig', [
|
||||
|
||||
@@ -34,8 +34,8 @@ final class SeoController extends AbstractController
|
||||
// Only published pages that are allowed to be indexed. A draft or a noindex page in the
|
||||
// sitemap would ask a search engine to fetch something it must not list.
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL ORDER BY p.path",
|
||||
['default'],
|
||||
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY p.path",
|
||||
['default', $this->settings->load()['notFoundPageId']],
|
||||
) as $row) {
|
||||
$entries[] = [
|
||||
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
|
||||
@@ -99,8 +99,8 @@ final class SeoController extends AbstractController
|
||||
}
|
||||
foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
|
||||
$rows = $this->db->fetchAllAssociative(
|
||||
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? ORDER BY p.path",
|
||||
['default', $type],
|
||||
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? AND p.id <> ? ORDER BY p.path",
|
||||
['default', $type, $settings['notFoundPageId']],
|
||||
);
|
||||
if ([] === $rows) {
|
||||
continue;
|
||||
|
||||
@@ -5,11 +5,13 @@ namespace App\Controller;
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -17,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme, private NotFoundPage $notFoundPage)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,6 +37,7 @@ final class SettingsController extends AbstractController
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $this->settings->load(),
|
||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||
'choosableHome' => $choosable,
|
||||
'consentGroups' => Consent::GROUPS,
|
||||
@@ -63,6 +66,34 @@ final class SettingsController extends AbstractController
|
||||
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/not-found', name: 'admin_not_found_save', methods: ['POST'])]
|
||||
public function saveNotFound(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$expectedId = $request->request->getString('expectedId');
|
||||
if ('create' === $request->request->getString('action')) {
|
||||
$id = $this->notFoundPage->create($expectedId);
|
||||
$this->addFlash('success', 'Die 404-Vorlage wurde als Entwurf angelegt und zugewiesen. Nach dem Veröffentlichen ersetzt sie die Standard-404.');
|
||||
|
||||
return $this->redirectToRoute('admin_editor', ['id' => $id]);
|
||||
}
|
||||
$id = $request->request->getString('notFoundPageId');
|
||||
$this->settings->assignNotFoundPage($id, $expectedId);
|
||||
$selection = $this->notFoundPage->selected();
|
||||
$message = '' === $id ? 'Die Standard-404 im Website-Design ist wieder aktiv.'
|
||||
: ($selection['published'] ? 'Die 404-Seite wurde zugewiesen.' : 'Die Seite wurde zugewiesen. Bis zu ihrer Veröffentlichung erscheint die Standard-404.');
|
||||
$this->addFlash('success', $message);
|
||||
} catch (InvalidPage|StaleWrite $error) {
|
||||
$this->addFlash('error', $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'not-found']);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||
public function saveRedirect(Request $request): Response
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
@@ -25,6 +26,8 @@ final class ThemeController extends AbstractController
|
||||
'theme' => $theme,
|
||||
'fallbacks' => Theme::FALLBACKS,
|
||||
'weights' => Theme::WEIGHTS,
|
||||
'buttonOptions' => ButtonDesign::OPTIONS,
|
||||
'buttonPresets' => ButtonDesign::PRESETS,
|
||||
'fontsLocal' => $this->fonts->hasLocal($theme),
|
||||
'fontsMissing' => $this->fonts->missing($theme),
|
||||
]);
|
||||
@@ -83,7 +86,7 @@ final class ThemeController extends AbstractController
|
||||
$fonts[] = $row;
|
||||
}
|
||||
try {
|
||||
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'));
|
||||
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'), $request->request->has('buttons') ? $request->request->all('buttons') : null);
|
||||
} catch (InvalidPage $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Controller\PageController;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/** Also handle human-facing unknown URLs outside the content slug grammar. */
|
||||
final class PublicNotFoundSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private PageController $pages, private string $adminPath)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::EXCEPTION => ['onException', 10]];
|
||||
}
|
||||
|
||||
public function onException(ExceptionEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
if (!$event->isMainRequest() || !$event->getThrowable() instanceof NotFoundHttpException
|
||||
|| $request->attributes->has('_route') || !in_array($request->getMethod(), ['GET', 'HEAD'], true)
|
||||
|| $request->isXmlHttpRequest() || 'html' !== $request->getPreferredFormat('html')) {
|
||||
return;
|
||||
}
|
||||
$path = $request->getPathInfo();
|
||||
if (preg_match('#^/(?:'.preg_quote($this->adminPath, '#').'|media|assets|formular|install)(?:/|$)#', $path)
|
||||
|| preg_match('/\.(?:css|js|mjs|json|xml|txt|png|jpe?g|webp|gif|svg|ico|woff2?|ttf|pdf)$/iD', $path)) {
|
||||
return;
|
||||
}
|
||||
$event->setResponse($this->pages->notFound($request));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,28 @@ use Twig\TwigFunction;
|
||||
*/
|
||||
final class VersionExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private Updater $updater)
|
||||
public function __construct(private Updater $updater, private \Doctrine\DBAL\Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [new TwigFunction('kant_version', $this->updater->version(...))];
|
||||
return [new TwigFunction('kant_version', $this->updater->version(...)), new TwigFunction('kant_update_version', $this->available(...))];
|
||||
}
|
||||
|
||||
public function available(): ?string
|
||||
{
|
||||
// The daily worker already checks releases. Rendering navigation never contacts Git.
|
||||
try {
|
||||
$message = $this->db->fetchOne('SELECT last_message FROM scheduled_task WHERE name = ?', ['update-check']);
|
||||
if (is_string($message) && 1 === preg_match('/^Fassung (\d+\.\d+\.\d+) ist verfügbar/', $message, $match)
|
||||
&& version_compare($match[1], $this->updater->version(), '>')) {
|
||||
return $match[1];
|
||||
}
|
||||
} catch (\Doctrine\DBAL\Exception) {
|
||||
// An installation still waiting for migrations has no task table yet.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||
nicht mehr. #}
|
||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
|
||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||
{%- endfor %}
|
||||
}}</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %}
|
||||
{% block title %}{{ subject.title }} bearbeiten{% endblock %}
|
||||
{% block body_class %}editor-body{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block body %}
|
||||
{# The API base decides what is edited. An area is shared, so it has no draft and no
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<input type="hidden" data-key="level" name="items[{{ n }}][level]" value="{{ row.level }}">
|
||||
<input type="hidden" data-key="type" name="items[{{ n }}][type]" value="{{ row.type }}">
|
||||
<details>
|
||||
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="muted">{{ row.type == 'page' ? 'Inhalt' : 'Eigener Link' }}</span></summary>
|
||||
<summary><button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg></button><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<div class="menu-entry-fields">
|
||||
<label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}">
|
||||
{% if row.type == 'page' %}
|
||||
@@ -13,6 +13,7 @@
|
||||
<label for="menu-url-{{ n }}">Adresse</label><input id="menu-url-{{ n }}" data-key="url" name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt">
|
||||
{% endif %}
|
||||
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
|
||||
<div class="menu-icon-fields"><div><label for="menu-icon-{{ n }}">Icon</label><select id="menu-icon-{{ n }}" data-key="icon" name="items[{{ n }}][icon]"><option value="">Kein Icon</option>{% for icon in menuIcons %}<option value="{{ icon }}"{{ row.icon|default('') == icon ? ' selected' : '' }}>{{ icon }}</option>{% endfor %}</select></div><div><label for="menu-iconPosition-{{ n }}">Icon-Position</label><select id="menu-iconPosition-{{ n }}" data-key="iconPosition" name="items[{{ n }}][iconPosition]"><option value="before"{{ row.iconPosition|default('before') == 'before' ? ' selected' : '' }}>Vor dem Text</option><option value="after"{{ row.iconPosition|default('before') == 'after' ? ' selected' : '' }}>Nach dem Text</option></select></div></div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{% for icon in menuIcons %}<template id="menu-icon-{{ icon }}">{% include 'blocks/icon.html.twig' with {name: icon} only %}</template>{% endfor %}
|
||||
@@ -5,8 +5,9 @@
|
||||
{% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'admin/menu-icons.html.twig' %}
|
||||
<nav class="menu-slots" aria-label="Menü auswählen">{% for key, label in slots %}<a href="{{ path('admin_menus', {slot: key}) }}"{{ key == slot ? ' aria-current="page"' : '' }}>{{ label }}</a>{% endfor %}</nav>
|
||||
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form">
|
||||
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}"><input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
|
||||
<aside class="menu-add" aria-label="Menüeinträge hinzufügen">
|
||||
<section class="card card-body"><h2>Seiten und Beiträge</h2>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Einstellungen{% endblock %}
|
||||
{% block heading %}Unternehmen und Adressen{% endblock %}
|
||||
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if missingLegal is not empty %}
|
||||
@@ -20,6 +21,36 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section class="stack-section" id="not-found" aria-labelledby="not-found-title">
|
||||
<div class="section-head"><h2 id="not-found-title">404-Seite</h2><a class="text-button" href="/__404-vorschau__" target="_blank" rel="noopener">404-Vorschau ↗</a></div>
|
||||
<div class="card card-body">
|
||||
<p>Bei unbekannten Adressen erscheint diese Seite mit dem Standard-Header, Standard-Footer und Design deiner Website.</p>
|
||||
{% if notFoundSelection %}
|
||||
<p class="notice">{{ notFoundSelection.published ? 'Aktiv:' : 'Zugewiesener Entwurf:' }} <strong>{{ notFoundSelection.title }}</strong>{% if not notFoundSelection.published %} · Bis zur Veröffentlichung erscheint die Standard-404.{% endif %}</p>
|
||||
<p><a class="secondary" href="{{ path('admin_editor', {id: notFoundSelection.id}) }}">404-Seite bearbeiten</a></p>
|
||||
{% else %}
|
||||
<p class="notice">Aktiv: Standard-404 im Website-Design.</p>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_PUBLISHER') %}
|
||||
<form action="{{ path('admin_not_found_save') }}" method="post" id="not-found-form">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<input type="hidden" name="expectedId" value="{{ settings.notFoundPageId }}">
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'not-found-picker', name: 'notFoundPageId', type: 'page', label: '404-Seite suchen', selected: notFoundSelection} only %}
|
||||
<p class="field-help">Nur der veröffentlichte Stand wird angezeigt. Ohne Auswahl wird die Standard-404 verwendet.</p>
|
||||
<button class="primary" type="submit">404-Zuordnung speichern</button>
|
||||
</form>
|
||||
{% if not notFoundSelection %}
|
||||
<form action="{{ path('admin_not_found_save') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<input type="hidden" name="expectedId" value="{{ settings.notFoundPageId }}">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<p class="field-help">Noch keine passende Seite? Lege eine fertige Vorlage an und bearbeite sie im Editor.</p>
|
||||
<button class="secondary" type="submit">404-Vorlage anlegen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}<p class="field-help">Die Zuordnung kann von einem Benutzer mit Veröffentlichungsrechten geändert werden.</p>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="stack-section">
|
||||
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
|
||||
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</nav>
|
||||
</details>
|
||||
<div class="shell-foot">
|
||||
<p class="shell-version">Fassung {{ kant_version() }}</p>
|
||||
<p class="shell-version">{% if is_granted('ROLE_ADMIN') %}<a href="{{ path('admin_system') }}">Fassung {{ kant_version() }}</a>{% set updateVersion = kant_update_version() %}{% if updateVersion %}<a class="version-update" href="{{ path('admin_system', {pruefen: 1}) }}" aria-label="Update {{ updateVersion }} verfügbar. Aktualisierung öffnen.">Update {{ updateVersion }} verfügbar</a>{% endif %}{% else %}Fassung {{ kant_version() }}{% endif %}</p>
|
||||
<a class="shell-user" href="{{ path('admin_account') }}" aria-label="Mein Konto: {{ app.user.userIdentifier }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
|
||||
<form action="{{ path('admin_logout') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'theme' %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/theme-buttons.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block title %}Design{% endblock %}
|
||||
{% block heading %}Farben, Schriften, Grundtypografie{% endblock %}
|
||||
{% block lead %}Das ist die Grundlage für die ganze Website. Jeder Block erbt von hier und kann jeden Wert einzeln überschreiben – je Gerät und beim Überfahren.{% endblock %}
|
||||
@@ -108,6 +110,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
|
||||
<fieldset class="button-defaults" id="button-defaults" data-schema="{{ {colors: theme.colors, fonts: theme.fonts, buttonPresets: buttonPresets}|json_encode|e('html_attr') }}">
|
||||
<legend>Standardbutton</legend>
|
||||
<p class="field-help">Die Grundlage für alle Button-Elemente. Eigene Varianten und Gestaltung im Editor haben Vorrang.</p>
|
||||
<div class="button-defaults-layout">
|
||||
<div class="fields">
|
||||
{% for key, options in buttonOptions %}<div><label for="buttons-{{ key }}">{{ {shape: 'Form', appearance: 'Darstellung', size: 'Button-Größe'}[key] }}</label><select id="buttons-{{ key }}" name="buttons[{{ key }}]">{% for value, label in options %}<option value="{{ value }}"{{ theme.buttons[key] == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>{% endfor %}
|
||||
<div><label for="buttons-fontFamily">Button-Schrift</label><select id="buttons-fontFamily" name="buttons[fontFamily]"><option value="inherit">Schrift der Umgebung übernehmen</option>{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.buttons.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}</select></div>
|
||||
<div><label for="buttons-fontSize">Button-Schriftgröße (px)</label><input id="buttons-fontSize" type="number" min="10" max="72" step="0.5" name="buttons[fontSize]" value="{{ theme.buttons.fontSize }}"></div>
|
||||
<div><label for="buttons-fontWeight">Button-Schriftstärke</label><select id="buttons-fontWeight" name="buttons[fontWeight]">{% for weight in weights %}<option value="{{ weight }}"{{ theme.buttons.fontWeight == weight ? ' selected' : '' }}>{{ weight }}</option>{% endfor %}</select></div>
|
||||
<div><label for="buttons-letterSpacing">Buchstabenabstand (px)</label><input id="buttons-letterSpacing" type="number" min="-1" max="8" step="0.1" name="buttons[letterSpacing]" value="{{ theme.buttons.letterSpacing }}"></div>
|
||||
<div><label for="buttons-textTransform">Schreibweise</label><select id="buttons-textTransform" name="buttons[textTransform]"><option value="none"{{ theme.buttons.textTransform == 'none' ? ' selected' : '' }}>Wie eingegeben</option><option value="uppercase"{{ theme.buttons.textTransform == 'uppercase' ? ' selected' : '' }}>GROSSBUCHSTABEN</option></select></div>
|
||||
{% for key, label in {backgroundColor: 'Button-Farbe', color: 'Button-Textfarbe', hoverBackgroundColor: 'Button-Farbe beim Überfahren', hoverColor: 'Textfarbe beim Überfahren'} %}<div><label for="buttons-{{ key }}">{{ label }}</label><input id="buttons-{{ key }}" name="buttons[{{ key }}]" value="{{ theme.buttons[key] }}" data-button-color></div>{% endfor %}
|
||||
</div>
|
||||
<div class="button-preview-column"><h3>Live-Vorschau</h3><div class="button-state-previews" aria-label="Standardbutton-Vorschau"></div><div class="button-preset-grid" role="group" aria-label="Standardbutton-Vorlagen">{% for preset in buttonPresets %}<button type="button" class="button-preset" data-button-preset aria-label="Standardbutton {{ preset.name }}"><span class="button-preset-label">{{ preset.name }}</span></button>{% endfor %}</div></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<button class="primary" type="submit">Design speichern</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
|
||||
<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}">
|
||||
<link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">
|
||||
</head>
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
@@ -53,17 +54,6 @@
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{% 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 %}
|
||||
{% for key, value in layout.effects|default({}) %}{% set designClasses = designClasses ~ ' fx-' ~ key ~ '-' ~ value %}{% endfor %}
|
||||
{% if type in ['section', 'row', 'column'] %}
|
||||
{% set tag = type == 'section' ? 'section' : 'div' %}
|
||||
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
|
||||
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}{{ block.anchor|default('') ? ' has-anchor' : '' }}">
|
||||
{% if block.anchor|default('') %}<span id="{{ block.anchor }}" class="block-anchor" aria-hidden="true"></span>{% endif %}
|
||||
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
|
||||
{% 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 %}
|
||||
{% if type == 'section' %}</div>{% endif %}
|
||||
@@ -24,7 +25,8 @@
|
||||
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
|
||||
{% endif %}
|
||||
{% elseif type == 'button' %}
|
||||
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
{% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %}
|
||||
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
{% elseif type == 'image' %}
|
||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||
@@ -66,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 }}">
|
||||
@@ -91,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>
|
||||
@@ -112,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 %}
|
||||
@@ -136,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>
|
||||
@@ -182,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' %}
|
||||
@@ -244,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 %}
|
||||
@@ -0,0 +1 @@
|
||||
<span class="nav-label">{% if link.icon|default('') and link.iconPosition|default('before') == 'before' %}{% include 'blocks/icon.html.twig' with {name: link.icon} only %}{% endif %}<span>{{ link.label }}</span>{% if link.icon|default('') and link.iconPosition|default('before') == 'after' %}{% include 'blocks/icon.html.twig' with {name: link.icon} only %}{% endif %}</span>
|
||||
@@ -4,14 +4,14 @@
|
||||
<li>
|
||||
{% if link.children is not empty %}
|
||||
<details class="menu-branch">
|
||||
<summary>{{ link.label }}</summary>
|
||||
<summary>{% include 'nav-label.html.twig' with {link: link} only %}</summary>
|
||||
<div class="menu-branch-body">
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{% include 'nav-label.html.twig' with {link: link} only %}</a>
|
||||
{% include 'nav.html.twig' with {links: link.children, level: (level|default(1)) + 1} only %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{% include 'nav-label.html.twig' with {link: link} only %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -2,17 +2,6 @@
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Seite nicht gefunden · KantSites</title><link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">{% include 'favicon.html.twig' %}</head>
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
<body class="{{ consentOpen ? 'consent-open' : '' }}"><main class="shell error-page"><p>404 · KantSites</p><h1>Hier ist noch keine Seite.</h1><p>Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.</p><a class="button" href="/">Zur Startseite</a></main>
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
{% set seoTitle = page.seo_title|default('') ?: page.document.title %}
|
||||
<title>{{ seoTitle }}</title>
|
||||
<meta name="description" content="{{ page.document.description }}">
|
||||
<link rel="canonical" href="{{ canonical }}">
|
||||
{% if canonical|default(null) %}<link rel="canonical" href="{{ canonical }}">{% endif %}
|
||||
{% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %}
|
||||
<meta property="og:title" content="{{ seoTitle }}">
|
||||
<meta property="og:description" content="{{ page.document.description }}">
|
||||
<meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}">
|
||||
<meta property="og:url" content="{{ canonical }}">
|
||||
{% if canonical|default(null) %}<meta property="og:url" content="{{ canonical }}">{% endif %}
|
||||
{# In der Vorschau des Editors gibt es den Websitenamen nicht - dort fehlt das Feld einfach. #}
|
||||
{% if page.website_name|default('') %}<meta property="og:site_name" content="{{ page.website_name }}">{% endif %}
|
||||
<meta property="og:locale" content="{{ page.locale == 'de' ? 'de_DE' : page.locale }}">
|
||||
@@ -36,6 +36,7 @@
|
||||
<link rel="stylesheet" href="{{ styleBundle }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
|
||||
<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}">
|
||||
{% if localFonts|default([]) %}<link rel="stylesheet" href="/assets/fonts/fonts.css?v={{ kant_version() }}">{% endif %}
|
||||
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">{% endif %}
|
||||
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css?v={{ kant_version() }}">{% endif %}
|
||||
@@ -91,18 +92,6 @@
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{# Nur, wenn die Seite die Zeile nicht selbst enthaelt: sonst stuende sie zweimal da. #}
|
||||
{% if (legal is not empty or revocable) and not hasLegalBlock|default(false) %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% set oben = siteSettings.load() %}
|
||||
{% if oben.scrollTop|default(false) and not editing|default(false) %}
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. #}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+135
-22
@@ -10,6 +10,7 @@ use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostLayouts;
|
||||
@@ -296,6 +297,115 @@ final class CmsTest extends TestCase
|
||||
self::assertSame(405, $this->request('/', 'POST')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDefault404UsesTheNormalAreasAndThemeForEveryPublicUrlShape(): void
|
||||
{
|
||||
foreach (['/missing', '/Missing/old.html', '/eins/zwei/drei/vier/fuenf', '/__404-vorschau__', '/kategorie/missing'] as $path) {
|
||||
$response = $this->request($path);
|
||||
self::assertSame(404, $response->getStatusCode(), $path);
|
||||
self::assertSame('noindex, follow', $response->headers->get('X-Robots-Tag'));
|
||||
self::assertFalse($response->headers->has('Location'));
|
||||
$html = $response->getContent();
|
||||
self::assertStringContainsString('Seite nicht gefunden', $html);
|
||||
self::assertStringContainsString('class="site-header', $html);
|
||||
self::assertStringContainsString('class="site-footer', $html);
|
||||
self::assertStringContainsString('--k-', $html);
|
||||
self::assertStringNotContainsString('rel="canonical"', $html);
|
||||
self::assertStringNotContainsString('application/ld+json', $html);
|
||||
}
|
||||
$head = $this->request('/Missing/old.html', 'HEAD');
|
||||
self::assertSame(404, $head->getStatusCode());
|
||||
self::assertSame('', $head->getContent());
|
||||
}
|
||||
|
||||
public function testCustom404KeepsDraftPrivateAndSurvivesRenamingAndFallsBackAfterDeletion(): void
|
||||
{
|
||||
$document = NotFoundPage::document();
|
||||
$document['blocks'][0]['children'][1]['props']['text'] = 'Unser individueller Fehlerhinweis';
|
||||
$id = $this->pages->createPage('fehlerseite', null, 'page', $document);
|
||||
$this->site->assignNotFoundPage($id, '');
|
||||
self::assertStringNotContainsString('Unser individueller Fehlerhinweis', $this->request('/unbekannt')->getContent());
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
$this->pages->saveAreas($id, 'none', 'none', 2);
|
||||
foreach (['/unbekannt', '/fehlerseite', '/alte.Datei.html'] as $url) {
|
||||
$response = $this->request($url);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
self::assertStringContainsString('Unser individueller Fehlerhinweis', $response->getContent());
|
||||
self::assertStringContainsString('class="site-header', $response->getContent());
|
||||
self::assertStringContainsString('class="site-footer', $response->getContent());
|
||||
}
|
||||
$this->site->save(['homePath' => 'home', 'llmsTxt' => true]);
|
||||
self::assertSame($id, $this->site->load()['notFoundPageId']);
|
||||
self::assertStringNotContainsString('/fehlerseite', $this->request('/sitemap.xml')->getContent());
|
||||
self::assertStringNotContainsString('/fehlerseite', $this->request('/llms.txt')->getContent());
|
||||
$this->pages->movePage($id, 'fehler-umbenannt', null, 3, $this->redirects);
|
||||
self::assertStringContainsString('Unser individueller Fehlerhinweis', $this->request('/weiterhin-falsch')->getContent());
|
||||
$document['blocks'][0]['children'][1]['props']['text'] = 'Noch geheimer Entwurf';
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$revision = $this->pages->saveDraft($id, $document, $page['version']);
|
||||
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $revision, $page['version']);
|
||||
self::assertStringContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->trashPage($id, $page['version']);
|
||||
self::assertStringContainsString('Seite nicht gefunden', $this->request('/weiterhin-falsch')->getContent());
|
||||
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
|
||||
}
|
||||
|
||||
public function test404CreationPreservesExistingPagesAndRejectsStaleAssignments(): void
|
||||
{
|
||||
$existing = $this->pages->createPage('404', null, 'page', DemoContent::starter('Vorhandene Seite'));
|
||||
$service = new NotFoundPage($this->site, $this->pages, $this->db);
|
||||
$id = $service->create('');
|
||||
$created = $this->pages->findForEditor($id);
|
||||
self::assertSame('404-2', $created['path']);
|
||||
self::assertNull($created['published_revision_id']);
|
||||
self::assertSame('noindex', $created['robots']);
|
||||
self::assertSame('Vorhandene Seite', $this->pages->findForEditor($existing)['document']['title']);
|
||||
$count = $this->db->fetchOne('SELECT COUNT(*) FROM page');
|
||||
try {
|
||||
$service->create('');
|
||||
self::fail('Repeated creation must not create another page.');
|
||||
} catch (InvalidPage) {
|
||||
self::assertSame($count, $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
}
|
||||
try {
|
||||
$this->site->assignNotFoundPage('', '');
|
||||
self::fail('A stale settings form must not reset a newer assignment.');
|
||||
} catch (StaleWrite) {
|
||||
self::assertSame($id, $this->site->load()['notFoundPageId']);
|
||||
}
|
||||
foreach (['page-1', 'missing', $this->pages->createPage('beitrag', null, 'post', DemoContent::starter('Beitrag', 'post'))] as $invalid) {
|
||||
try {
|
||||
$this->site->assignNotFoundPage($invalid, $id);
|
||||
self::fail('The home page, posts and missing pages cannot serve as the 404 page.');
|
||||
} catch (InvalidPage) {
|
||||
self::assertSame($id, $this->site->load()['notFoundPageId']);
|
||||
}
|
||||
}
|
||||
$this->site->assignNotFoundPage('', $id);
|
||||
self::assertSame('', $this->site->load()['notFoundPageId']);
|
||||
}
|
||||
|
||||
public function testCustom404DoesNotReplaceRedirectsGoneResourcesOrAdminEndpoints(): void
|
||||
{
|
||||
$document = NotFoundPage::document();
|
||||
$document['title'] = 'CUSTOM404MARKER';
|
||||
$id = $this->pages->createPage('fehler', null, 'page', $document);
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
$this->site->assignNotFoundPage($id, '');
|
||||
$this->redirects->save('umgezogen', 'home', 301);
|
||||
$this->redirects->save('entfernt', '', 410);
|
||||
self::assertSame(301, $this->request('/umgezogen')->getStatusCode());
|
||||
self::assertSame(410, $this->request('/entfernt')->getStatusCode());
|
||||
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request('/entfernt')->getContent());
|
||||
foreach (['/assets/missing.js', '/media/missing', '/admin/api/missing', '/missing.json', '/llms.txt'] as $url) {
|
||||
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request($url)->getContent(), $url);
|
||||
}
|
||||
}
|
||||
|
||||
public function testHomeAliasRedirectsToCanonical(): void
|
||||
{
|
||||
$response = $this->request('/home');
|
||||
@@ -464,12 +574,13 @@ 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"'));
|
||||
self::assertStringContainsString('<a href="/" aria-current="page">Start</a>', $html);
|
||||
// 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.
|
||||
$other = $this->request('/ueber-uns')->getContent();
|
||||
self::assertStringContainsString('<a href="/ueber-uns" aria-current="page">Ueber uns</a>', $other);
|
||||
self::assertMatchesRegularExpression('~<a href="/ueber-uns" aria-current="page">\s*<span class="nav-label"><span>Ueber uns</span></span>\s*</a>~', $other);
|
||||
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
|
||||
}
|
||||
|
||||
@@ -1008,12 +1119,9 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testTheLegalLineCanLiveInsideTheFooterInsteadOfUnderneathIt(): void
|
||||
public function testLegalLinksAppearOnlyWhereAnAuthorPlacesThem(): void
|
||||
{
|
||||
// Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser
|
||||
// lösen." Die eingebaute Leiste klebte als heller Streifen unter einem dunklen Fuß. Als
|
||||
// Baustein steht sie dort, wo sie hingehört, und erbt die Farben ihres Bereichs.
|
||||
// Zwei verschiedene Seiten: die Zeile fuehrt zu Datenschutz und Impressum.
|
||||
// Die hinterlegten Adressen erzeugen keine zusätzliche Leiste unter dem Footer.
|
||||
$datenschutz = $this->pages->createPage('datenschutz', null, 'page', DemoContent::starter('Datenschutz'));
|
||||
$seite = $this->pages->findForEditor($datenschutz);
|
||||
$this->pages->publish($datenschutz, $seite['draft_revision_id'], $seite['version']);
|
||||
@@ -1022,9 +1130,10 @@ final class CmsTest extends TestCase
|
||||
$document['schemaVersion'] = 19;
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
// Ohne Baustein: die eingebaute Leiste am Seitenende.
|
||||
// Ohne selbst platzierte Links bleibt die Seite ohne Rechtszeile.
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<nav class="legal-bar"', $html);
|
||||
self::assertStringNotContainsString('<nav class="legal-bar"', $html);
|
||||
self::assertStringNotContainsString('<a href="/datenschutz">', $html);
|
||||
|
||||
// Mit Baustein: nur noch dieser, und zwar an seiner Stelle im Dokument.
|
||||
$document['blocks'][] = ['id' => 'rechtszeile', 'type' => 'legal', 'props' => []];
|
||||
@@ -1032,7 +1141,7 @@ final class CmsTest extends TestCase
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
|
||||
$mit = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<nav id="rechtszeile" class="block-legal"', $mit);
|
||||
self::assertStringNotContainsString('<nav class="legal-bar"', $mit, 'Zweimal dieselbe Zeile ist schlechter als einmal.');
|
||||
self::assertStringNotContainsString('<nav class="legal-bar"', $mit);
|
||||
self::assertStringContainsString('Datenschutz', $mit);
|
||||
}
|
||||
|
||||
@@ -2461,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');
|
||||
@@ -2591,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);
|
||||
@@ -3187,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'));
|
||||
}
|
||||
|
||||
@@ -3446,7 +3557,7 @@ final class CmsTest extends TestCase
|
||||
yield 'corners' => [['borderRadius' => ['topLeft' => '18px']], '#b1{border-top-left-radius:18px}'];
|
||||
yield 'outline' => [['outlineWidth' => '3px', 'outlineColor' => '#2c6b4f', 'outlineOffset' => '2px'], '#b1{outline-width:3px;outline-color:#2c6b4f;outline-offset:2px}'];
|
||||
yield 'position and layer' => [['position' => 'absolute', 'top' => '0', 'right' => '12px', 'zIndex' => '5'], '#b1{position:absolute;top:0;right:12px;z-index:5}'];
|
||||
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}'];
|
||||
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}', 'column'];
|
||||
yield 'separate gaps' => [['rowGap' => '8px', 'columnGap' => '24px'], '#b1{row-gap:8px;column-gap:24px}'];
|
||||
yield 'inner shadow' => [['shadowY' => '4px', 'shadowBlur' => '12px', 'shadowInset' => 'innen'], '#b1{box-shadow:inset 0 4px 12px 0 #00000026}'];
|
||||
yield 'outer shadow stays outer' => [['shadowY' => '4px'], '#b1{box-shadow:0 4px 0 0 #00000026}'];
|
||||
@@ -3462,11 +3573,11 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
|
||||
#[DataProvider('styleDeclarations')]
|
||||
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected): void
|
||||
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected, string $type = 'image'): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
self::assertSame($expected, $compiler->compileDocument([
|
||||
'blocks' => [['id' => 'b1', 'type' => 'image', 'style' => ['desktop' => $style]]],
|
||||
'blocks' => [['id' => 'b1', 'type' => $type, 'style' => ['desktop' => $style]]],
|
||||
]));
|
||||
// And what compiles has to survive the validator, or the editor could offer it in vain.
|
||||
$document = DemoContent::starter('Mit Stil');
|
||||
@@ -3485,8 +3596,7 @@ final class CmsTest extends TestCase
|
||||
$panel = (string) file_get_contents(__DIR__.'/../public/assets/design-panel.js');
|
||||
foreach (array_keys($groups) as $group) {
|
||||
// A group the panel does not know would hold its properties in an unnamed accordion.
|
||||
self::assertMatchesRegularExpression('/
|
||||
'.$group.': /', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
|
||||
self::assertMatchesRegularExpression('/^\h*'.preg_quote($group, '/').':\h/m', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3500,12 +3610,12 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
self::assertSame('', $this->site->load()[$key]);
|
||||
}
|
||||
// A page that exists is accepted and then linked on every page.
|
||||
// A page that exists is accepted; placing its link remains an editorial choice.
|
||||
$this->publishedPage('datenschutz', 'Datenschutz');
|
||||
$this->site->save(['homePath' => 'home', 'privacyPath' => 'datenschutz']);
|
||||
self::assertSame('datenschutz', $this->site->load()['privacyPath']);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
|
||||
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
public function testADeliveredPageIncrementsOneCounterAndTheAdministrationDoesNot(): void
|
||||
@@ -3540,7 +3650,10 @@ final class CmsTest extends TestCase
|
||||
|
||||
public function testTheQuestionAppearsUntilItIsAnsweredAndCanBeChangedAgain(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['schemaVersion'] = 19;
|
||||
$document['blocks'][] = ['id' => 'consent-links', 'type' => 'legal', 'props' => []];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$this->askConsent();
|
||||
|
||||
$response = $this->request('/');
|
||||
|
||||
@@ -82,6 +82,39 @@ final class EditorSecurityTest extends WebTestCase
|
||||
return $client;
|
||||
}
|
||||
|
||||
public function test404SettingsCreateDraftAndProtectAssignmentFromCsrfAndStaleWrites(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
$crawler = $client->request('GET', '/admin/settings');
|
||||
self::assertSelectorExists('#not-found-picker[role="combobox"]');
|
||||
$form = $crawler->selectButton('404-Vorlage anlegen')->form();
|
||||
$values = $form->getPhpValues();
|
||||
$client->request('POST', '/admin/settings/not-found', [...$values, '_csrf_token' => 'invalid']);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
$client->submit($form);
|
||||
self::assertResponseRedirects();
|
||||
$id = json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId'];
|
||||
self::assertResponseRedirects('/admin/pages/'.$id);
|
||||
self::assertNull($this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id]));
|
||||
$client->request('POST', '/admin/settings/not-found', ['_csrf_token' => $values['_csrf_token'], 'notFoundPageId' => '', 'expectedId' => '']);
|
||||
self::assertSame($id, json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']);
|
||||
$client->followRedirect();
|
||||
self::assertSelectorTextContains('body', 'inzwischen geändert');
|
||||
$crawler = $client->request('GET', '/admin/settings');
|
||||
$client->submit($crawler->selectButton('404-Zuordnung speichern')->form(['notFoundPageId' => '']));
|
||||
self::assertSame('', json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']);
|
||||
}
|
||||
|
||||
public function test404AssignmentRequiresPublicationRights(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$client->request('GET', '/admin/settings');
|
||||
self::assertSelectorNotExists('#not-found-form');
|
||||
$client->request('POST', '/admin/settings/not-found', ['action' => 'create']);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
}
|
||||
|
||||
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
@@ -254,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']));
|
||||
}
|
||||
@@ -334,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);
|
||||
@@ -685,7 +719,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertResponseRedirects('/admin/media?q=0');
|
||||
}
|
||||
|
||||
public function testTheMandatoryLegalPagesAreCreatedPublishedAndLinkedEverywhere(): void
|
||||
public function testLegalPagesCanBeCreatedWithoutInjectingFooterLinks(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/settings');
|
||||
@@ -712,11 +746,11 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertStringContainsString('NOCH AUSZUFÜLLEN', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
// And they are linked from an ordinary page, without anyone editing the footer.
|
||||
// Creating the pages does not alter the author's footer or append a link bar.
|
||||
$client->request('GET', '/');
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
||||
self::assertStringContainsString('<a href="/impressum">Impressum</a>', $content);
|
||||
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
||||
self::assertStringNotContainsString('<a href="/impressum">Impressum</a>', $content);
|
||||
|
||||
// Running it twice keeps the existing pages: they may already carry the real text.
|
||||
$before = $this->db->fetchOne('SELECT COUNT(*) FROM revision');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\StyleCompiler;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ImageAlignmentTest extends TestCase
|
||||
{
|
||||
public function testAlignmentOnlyAddsRulesToImagesWithAnExplicitSetting(): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
self::assertSame('#plain{width:50%}#text{align-self:center}', $compiler->compileDocument(['blocks' => [
|
||||
['id' => 'plain', 'type' => 'image', 'style' => ['desktop' => ['width' => '50%']]],
|
||||
['id' => 'text', 'type' => 'text', 'style' => ['desktop' => ['alignSelf' => 'center']]],
|
||||
]]));
|
||||
self::assertSame('', $compiler->compileDocument(['blocks' => [
|
||||
['id' => 'bad{selector}', 'type' => 'image', 'style' => ['desktop' => ['alignSelf' => 'center']]],
|
||||
]]));
|
||||
}
|
||||
|
||||
public function testResponsiveResetRestoresAuthoredMarginsAndClearsInnerAlignment(): void
|
||||
{
|
||||
foreach (['auto', 'stretch', 'baseline'] as $reset) {
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'logo', 'type' => 'image', 'style' => [
|
||||
'desktop' => ['width' => '50%', 'alignSelf' => 'center', 'margin' => ['left' => '11px', 'right' => '7px', 'top' => '20px']],
|
||||
'tablet' => ['alignSelf' => 'flex-end'],
|
||||
'mobile' => ['alignSelf' => $reset, 'margin' => ['left' => '15px']],
|
||||
],
|
||||
]]]);
|
||||
self::assertStringContainsString('#logo{margin-left:auto;margin-right:auto;justify-self:center}#logo img{margin-left:auto;margin-right:auto}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#logo{margin-left:auto;margin-right:0;justify-self:end}#logo img{margin-left:auto;margin-right:0}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#logo{margin-left:15px;margin-right:7px;justify-self:auto}#logo img{margin-left:0;margin-right:0}}', $css);
|
||||
self::assertSame(1, substr_count($css, 'margin-top:20px'));
|
||||
}
|
||||
}
|
||||
|
||||
public function testChangingParentFlexAxisRecomputesInheritedImageAlignment(): void
|
||||
{
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'group', 'type' => 'column', 'style' => [
|
||||
'desktop' => ['display' => 'inline-flex', 'flexDirection' => 'row-reverse'],
|
||||
'tablet' => ['flexDirection' => 'column-reverse'],
|
||||
'mobile' => ['flexDirection' => 'row'],
|
||||
], 'children' => [[
|
||||
'id' => 'logo', 'type' => 'image', 'style' => ['desktop' => ['alignSelf' => 'center', 'margin' => ['left' => '9px']]],
|
||||
]],
|
||||
]]]);
|
||||
self::assertStringContainsString('#logo{align-self:center;margin-left:9px}', $css);
|
||||
self::assertStringContainsString('#logo{margin-left:9px;margin-right:0;justify-self:auto}#logo img{margin-left:auto;margin-right:auto}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#logo{margin-left:auto;margin-right:auto;justify-self:center}#logo img{margin-left:auto;margin-right:auto}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#logo{margin-left:9px;margin-right:0;justify-self:auto}#logo img{margin-left:auto;margin-right:auto}}', $css);
|
||||
}
|
||||
|
||||
public function testSectionAccordionAndSliderInnerWrappersUseBlockAlignment(): void
|
||||
{
|
||||
foreach (['section', 'accordionitem', 'slider'] as $type) {
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'group', 'type' => $type, 'style' => ['desktop' => ['display' => 'flex']],
|
||||
'children' => [['id' => 'logo', 'type' => 'image', 'style' => ['desktop' => ['alignSelf' => 'center']]]],
|
||||
]]]);
|
||||
self::assertStringContainsString('#logo{margin-left:auto;margin-right:auto;justify-self:center}#logo img{margin-left:auto;margin-right:auto}', $css);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,17 @@ test('v6 containers accept only their own children and an accordion starts with
|
||||
assert.equal(canContain(block, { type: 'accordionitem' }), true);
|
||||
assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false);
|
||||
assert.equal(canContain(block.children[0], { type: 'text' }), true);
|
||||
// Selecting the group must not silently put a text outside it. Choose the entry first.
|
||||
assert.equal(insertionTarget(history.document, accordion, 'text'), null);
|
||||
assert.equal(insertBlock(history.document, 'text', accordion), null);
|
||||
const entry = block.children[0].id;
|
||||
const text = history.change(doc => insertBlock(doc, 'text', entry));
|
||||
assert.equal(locate(history.document.blocks, text).parent.id, entry);
|
||||
history.undo();
|
||||
assert.equal(locate(history.document.blocks, entry).block.children.length, 0);
|
||||
history.redo();
|
||||
assert.equal(locate(history.document.blocks, text).parent.id, entry);
|
||||
history.undo();
|
||||
|
||||
// A logo strip takes images only, like a gallery.
|
||||
const logos = history.change(doc => insertBlock(doc, 'logos', 's2'));
|
||||
|
||||
@@ -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