Compare commits

..
15 Commits
Author SHA1 Message Date
kantarciandClaude Fable 5.1 769b0fae2d Keep the slider moving in Safari and draw real dots in 1.11.2
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Safari cancels a smooth scroll on a snapping container and snaps back to
the slide it came from, so arrows and autoplay stuttered on the iPhone. The
slider switches snapping off for the ride and restores it on arrival. The
control dots are drawn as dots with the active one filled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 18:01:53 +02:00
kantarciandClaude Fable 5.1 9c8462d46d Document verified 1.11.1 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:45:54 +02:00
kantarciandClaude Fable 5.1 3785d3dfef Give the header scrolled colours and equal card heights in 1.11.1
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Cards, quotes and call-to-action bands side by side in a row are equal in
height again: the stacking margin meant for a column no longer applies inside
a row. The header can carry a second set of colours for the scrolled page,
the Flatsome transparent header: a "scrolled" state next to "hover", limited
to colours, shadow and opacity, marked by a small script only where it is
used, plus a setting that lets the header float over the first section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:44:53 +02:00
kantarciandClaude Fable 5.1 b634f486e5 Point the motion check at a block that keeps its entrance
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
The Kant-IT home page now animates blocks instead of whole sections, so the
check waits for the closing call to action rather than the contact section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:23:43 +02:00
kantarciandClaude Fable 5.1 2299184401 Document verified 1.11.0 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:42:54 +02:00
kantarciandClaude Fable 5.1 a1f8b905cd Rebuild the editor around one sidebar and play motion by default in 1.11.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
The editor follows the Flatsome UX Builder: dark top bar with the device
switch, one sidebar with tabs for elements, structure and page, an element
view with breadcrumbs, property tabs and actions, element cards, a context
menu in the preview and keyboard shortcuts that also work inside the preview.

Entrance and hover effects play for every visitor unless the website chooses
to respect the browser's reduced-motion preference (setting "motion", class
"motion-respect" on <html>). Waiting blocks stay hidden until they scroll into
view and are shown at once when the reader jumps past them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:41:04 +02:00
kantarci ebc16777d6 docs: record verified 1.10.2 update recovery and release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 15:38:24 +02:00
kantarci 7c1c86b864 fix: preserve running kernel cache during archive updates
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 15:35:52 +02:00
kantarci c82a37643b Document verified 1.10.1 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 15:23:48 +02:00
kantarci cff468167b Use standard form buttons and resume entrance effects in 1.10.1
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 15:22:42 +02:00
kantarci f182f9e125 Document verified 1.10.0 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 14:48:26 +02:00
kantarci 37c2942d1d Add device visibility and responsive navigation for 1.10.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 14:46:55 +02:00
kantarci eea6747d9e Document published 1.9.0 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 13:48:57 +02:00
kantarci 3391fea58b Add visual button presets, jump targets and menu controls
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 13:47:49 +02:00
kantarci bb4e422af8 Document verified image alignment release 1.8.4
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 13:19:58 +02:00
64 changed files with 2452 additions and 508 deletions

No files matched your search

+69 -1
View File
@@ -4,6 +4,68 @@ 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`.
## Slider auf dem iPhone (ab 1.11.2)
Safari bricht sanftes Weiterblättern in einem einrastenden Slider ab und springt zur vorigen Folie zurück; die Automatik und die Pfeile stotterten deshalb auf dem iPhone. Während der Fahrt ist das Einrasten jetzt ausgeschaltet und wird bei Ankunft auf der Folie wieder eingeschaltet. Die Punkte unter dem Slider sind echte Punkte, der aktive gefüllt, bei unveränderter Trefffläche von 44 px.
## Kopfbereich beim Scrollen und gleich hohe Karten (ab 1.11.1)
Der Kopfbereich kann für die gescrollte Seite eigene Farben tragen, wie der transparente Header in Flatsome: Kopfbereich im Editor öffnen, die Sektion wählen, **Design → Hintergrund**. Dort steht unter „Hintergrundfarbe“ die Zeile **„Hintergrundfarbe beim Scrollen“**; dasselbe gibt es für Textfarbe, Rahmenfarbe, Schattenfarbe und Deckkraft. Der normale Wert gilt am Seitenanfang, der Scroll-Wert sobald die Seite mehr als 40 px bewegt wurde, mit weichem Übergang. Für einen durchsichtigen Kopf am Anfang `#ffffff00` als normale Hintergrundfarbe eintragen. Damit der Kopf über der ersten Sektion liegt statt darüber zu stehen, unter **Einstellungen** „Kopfbereich über dem Inhalt schweben lassen“ einschalten; zusammen mit „oben halten“ bleibt er beim Scrollen sichtbar. Ohne JavaScript behält der Kopf seine normalen Farben.
Karten, Kundenstimmen und Aufruf-Bänder nebeneinander in einer Zeile sind wieder gleich hoch. Der Abstand, den gestapelte Karten in einer Spalte untereinander brauchen, hatte in der Zeile die zweite bis letzte Karte um 18 px niedriger gemacht.
## Editor mit einer Seitenleiste und Bewegung wie Flatsome (ab 1.11.0)
Der Editor folgt jetzt dem Aufbau des Flatsome UX Builders: eine dunkle Kopfzeile mit Gerätewahl in der Mitte, Rückgängig/Wiederholen, Bewegung abspielen, Speichern und Veröffentlichen; links **eine** Seitenleiste mit den Reitern **Elemente**, **Struktur** und **Seite**; rechts die Vorschau. Ein Klick auf ein Element in der Vorschau öffnet seine Optionen in der Seitenleiste: Name, Pfad (Seite › Sektion › Zeile), die Reiter Inhalt/Design/Erweitert, unten Verschieben, Duplizieren, Löschen. **‹** wählt das übergeordnete Element, **+** fügt in einen Container ein oder direkt hinter das Element, **✕** oder Escape hebt die Auswahl auf. Rechtsklick in der Vorschau öffnet ein Kontextmenü mit denselben Aktionen. Entf löscht das gewählte Element, sofern kein Feld den Fokus hat.
Die Elemente stehen als Kartenraster mit Symbol und Namen; was an der gewählten Stelle nicht passt, ist ausgegraut und sagt beim Überfahren warum. In der Struktur wählt ein Klick nur aus, **Bearbeiten** oder Doppelklick öffnet das Element. Die Sichtbarkeit je Gerät steht jetzt am Ende von Inhalt und Erweitert. Die verstellbaren Panelbreiten entfallen.
**Bewegung:** Einblendungen, Hover-Bewegungen und Slider-Automatik spielen standardmäßig bei jedem Besucher, wie bei Flatsome und den meisten Websites. Manche Browser melden „Bewegung reduzieren“, ohne dass jemand es eingestellt hat; bisher blieb dann jede Einblendung aus. Unter **Einstellungen → Einblendungen und Hover-Bewegungen** lässt sich „Browservorgabe beachten“ wählen; dann trägt `<html>` die Klasse `motion-respect`, und Skript wie CSS halten sich wieder an die Vorgabe. Einblendungen verstecken einen Block, bis er ins Bild scrollt, und blenden ihn dann von unsichtbar ein; wer per Sprungmarke oder schnellem Scrollen an einem Block vorbeikommt, sieht ihn sofort. Ohne JavaScript ist alles sichtbar.
Gezielt prüfen (isolierte Kopien; PHP mit SQLite3-Erweiterung):
```powershell
$env:PHP_BINARY = 'C:\php84\php.exe'
node scripts/motion-check.mjs
node scripts/editor-check.mjs
npm.cmd test
```
## 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.
@@ -30,6 +92,8 @@ Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website
## Anmelden und bearbeiten
**Scroll-Einblendungen prüfen:** Block auswählen → Design → Bewegung und Effekte → Einblendung beim Erscheinen → „Von links“ oder „Von rechts“. Anschließend speichern und veröffentlichen. Im Editor startet „Bewegung testen“ die Vorschauwiedergabe. Öffentliche Seiten animieren beim ersten Erscheinen; die Systemoption „Bewegung reduzieren“ unterdrückt Animationen. `node scripts/motion-check.mjs` prüft Scroll-Auslösung, beide Richtungen, Bewegungsreduktion und Ausgabe ohne JavaScript mit einer isolierten Kant-IT-Kopie. PHP mit SQLite3-Unterstützung über `PHP_BINARY` wählen, etwa in PowerShell `$env:PHP_BINARY='C:\php84\php.exe'`. Ergebnisse stehen unter `reports/motion/`.
**Bildausrichtung (ab 1.8.4):** Bild auswählen → Design → Layout → **Eigene Ausrichtung quer: Mitte**. Zentriert den Bildblock und auch kleinere Bilder innerhalb des Rahmens. Funktioniert mit Prozentbreite, maximaler Breite und Bildlinks; Anfang/Ende und Gerätewerte werden ebenfalls berücksichtigt. In nebeneinander angeordneten Flex-Gruppen bleibt die Querachse vertikal. Prüfen: `npm run test:image-alignment` auf einer isolierten Kant-IT-Kopie; Screenshots unter `reports/image-alignment/`.
**Akkordeon-Inhalte (ab 1.8.3):** Einträge bleiben im Editor geöffnet. Im Eintrag auf **Inhalt hinzufügen** klicken und links Text, Bild oder ein anderes Element auswählen; alternativ direkt in die sichtbare Inhaltsfläche ziehen. **Eintrag hinzufügen** steht unter dem Akkordeon und in dessen Inhaltsoptionen. Die Eintragsübersicht rechts und die Struktur links zeigen die Titel. **Beim Laden geöffnet** bestimmt ausschließlich den Zustand der veröffentlichten Seite. Prüfen: `npm run test:accordion`, Screenshots unter `reports/accordion/`.
@@ -58,7 +122,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.
@@ -153,6 +217,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.
+1 -1
View File
@@ -1 +1 @@
1.8.4
1.11.2
+54
View File
@@ -2,6 +2,58 @@
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 092 – Der Kopfbereich kennt einen gescrollten Zustand (14.09.2026)
Der transparente Header aus Flatsome ist zwei Dinge: ein Kopf, der über der ersten Sektion liegt, und Farben, die sich beim Scrollen ändern. Beides bleibt getrennt. Das Schweben ist eine Website-Einstellung (`headerOverlay` → `.site-header.is-overlay`, absolut, mit „oben halten“ fixiert), weil es das Layout jeder Seite betrifft. Die Farben sind Gestaltung des Kopfbereichs: neben `hover` gibt es je Gerät einen Block `scrolled`, beschränkt auf Farben, Schatten und Deckkraft (`StyleSchema::scrollable()`) – ein Kopf, der beim Scrollen wächst, würde die Seite unter dem Finger verschieben. Der Compiler gibt `html.is-scrolled #id{…}` plus einen Übergang aus; ein Skript von zehn Zeilen setzt die Klasse ab 40 px und wird nur geladen, wenn der Kopfbereich solche Werte hat, damit Seiten ohne sie ihre strenge Skriptrichtlinie behalten. Ohne Skript gilt der normale Zustand, eine lesbare Seite. Das Design-Panel zeigt die Zeilen „beim Scrollen“ nur im Kopfbereich, weil sie nur dort etwas bewirken; die Editor-Vorschau setzt die Klasse beim Scrollen selbst, da sie kein Skript ausführt. Kein Schemawechsel: `scrolled` ist ein optionaler Schlüssel, alte Dokumente bleiben gültig.
**Kartenhöhen.** Die Stapelabstände `.block-iconcard + .block-iconcard` (und Zitat/CTA) galten auch im Raster einer Zeile; dort liegt der Rand im Rasterfeld und macht jede Karte außer der ersten um 18 px niedriger. Die Abstände bleiben für Spalten, in `.block-row` werden sie auf null gesetzt – das Raster hat sein eigenes `gap`.
## ADR 091 – Eine Seitenleiste wie im Flatsome UX Builder; Bewegung spielt, bis die Website etwas anderes sagt (14.09.2026)
**Editor.** Drei Panels nebeneinander – Werkzeugkasten und Struktur links, Vorschau, Eigenschaften rechts – zeigten immer alles und damit nichts; Umut fand sich nicht zurecht, im Flatsome UX Builder schon. Der Editor übernimmt dessen Rhythmus: eine Seitenleiste, die genau eine Sache zeigt. Reiter für Elemente, Struktur und Seite; ein gewähltes Element öffnet seine eigene Ansicht mit Name, Pfad, den drei Eigenschaftenreitern und den Aktionen am Fuß und gibt sie beim Abwählen wieder frei. Die Reiter bleiben dabei erreichbar, damit Bibliothek und Baum einen Klick entfernt sind. Die Struktur wählt beim Klick nur aus und öffnet erst über „Bearbeiten“; sonst verschwände der Baum bei jedem Schritt. Rechtsklick in der Vorschau öffnet ein Kontextmenü im Editor-Dokument, dessen Position einmal aus Rahmen und Maßstab umgerechnet wird. Tastenkürzel gelten auch, wenn der Fokus in der Vorschau liegt: derselbe Handler hängt am Fenster und an jedem Vorschau-Dokument. Die Auswahl scrollt nur das Vorschau-Fenster, nicht per `scrollIntoView` – das zog das Editor-Dokument mit und schob die Kopfzeile aus dem Bild. Die verstellbaren Panelbreiten entfallen; eine feste Leiste ist der einfachere Vertrag. Bestehende IDs (`#library`, `#structure`, `#properties`, `#preview`, `#save-status` …) bleiben, die Browserprüfung wechselt die Ansicht über Helfer statt über versteckte Elemente zu klicken.
**Bewegung.** Bisher entschied allein die Browservorgabe `prefers-reduced-motion`, und die meldete Umuts Chrome als aktiv, ohne dass er sie gesetzt hatte; andere Websites bewegen sich bei ihm, weil sie die Vorgabe ignorieren. Die Entscheidung gehört der Website: Einstellung `motion` mit `always` (Standard, Flatsome-Verhalten) und `respect`. Der Server druckt bei `respect` die Klasse `motion-respect` auf `<html>`; `motion.js` und `slider.js` fragen `Klasse && Vorgabe`, das CSS schreibt die Hover-Fahrten unbedingt und nimmt sie in einem Überschreibblock `@media (prefers-reduced-motion: reduce) { html.motion-respect … { transform: none; transition: none } }` zurück – Farben und Schatten bleiben, nur die Fahrt fällt weg. Einblendungen verstecken einen wartenden Block per Inline-`opacity:0` (nur mit Skript, nur wenn Bewegung erlaubt ist) und blenden ihn mit `fill: backwards` von unsichtbar ein; ein `scroll`-Handler zeigt sofort, was oberhalb des Bildes liegt, weil ein Sprung per Sprungmarke oder Wiederherstellung sonst Blöcke dauerhaft unsichtbar ließe. Beim Abschalten (Präferenzwechsel unter `respect`, Editor-Stopp) werden alle versteckten Blöcke wieder eingeblendet. Barrierefreiheit: Einblendungen dauern unter einer Sekunde, blinken nicht und lassen sich per Einstellung an die Vorgabe binden; Lighthouse misst diese Vorgabe nicht. Kein Schema- und kein Datenbankwechsel: `motion` fällt bei fehlendem Wert auf `always` zurück.
## 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.
@@ -80,6 +132,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.
+116 -2
View File
@@ -1,14 +1,128 @@
# Projektstatus
## 1.11.2 – Slider stottert nicht mehr auf dem iPhone (14.09.2026)
**Anlass:** Umuts iPhone-Screenshot des Hero-Sliders („das buggt“). Safari bricht `scrollTo({behavior:'smooth'})` in einem Container mit `scroll-snap-type: x mandatory` ab und rastet auf die vorige Folie zurück; Pfeile und Automatik ruckeln.
**Umgesetzt:** `slider.js` schaltet das Einrasten für die Fahrt aus und stellt es bei Ankunft am Zielwert wieder her (spätestens nach zwei Sekunden, weil ein Hintergrund-Tab sanftes Scrollen pausiert); bei reduzierter Bewegung weiter sofortiges Springen. Die Punkte der Steuerung sind gezeichnete Punkte (aktiv gefüllt) statt eines Kastens um den Text, Trefffläche bleibt 44 px.
**Geprüft:** Playwright auf der Pilotkopie bei 375 und 1600 px: „Nächste Folie“ landet exakt auf dem Folienanfang (359 bzw. 666 px), Einrasten danach wieder `x mandatory`, Automatik blättert nach 7 s selbst weiter; 33 JavaScript-Tests. Der Safari-Fall selbst ist hier nicht ausführbar; die Maßnahme ist die dokumentierte Umgehung dieses WebKit-Verhaltens.
## 1.11.1 – Kopfbereich mit Scroll-Farben, gleich hohe Karten (14.09.2026)
**Anlass:** Umut sah auf der Startseite ungleich hohe Karten und wünschte sich für den Kopfbereich einstellbare Transparenz-Effekte beim Scrollen, wie in Flatsome.
**Umgesetzt:** (1) `.block-iconcard + .block-iconcard` und die verwandten Stapelabstände gelten in `.block-row` nicht mehr; die Karten in einer Zeile sind gleich hoch. (2) Neuer Zustand `scrolled` neben `hover` in der Gestaltung: `StyleSchema::scrollable()` (Hintergrund-, Text-, Rahmen-, Schattenfarbe, Deckkraft), Prüfung im `StyleCompiler`, Ausgabe als `html.is-scrolled #id{…}` mit Übergang; Design-Panel zeigt „… beim Scrollen“ nur im Kopfbereich; `scrolled.js` markiert die Seite ab 40 px, wird nur geladen, wenn der Kopfbereich Scroll-Werte hat (Seiten und Archive, CSP entsprechend); die Editor-Vorschau setzt die Klasse selbst. Neue Einstellung „Kopfbereich über dem Inhalt schweben lassen“ (`headerOverlay`, Klasse `is-overlay`, absolut bzw. fixiert mit „oben halten“). ADR 092.
**Geprüft:** Neuer Test `testAHeaderCanChangeItsColoursOnceThePageHasScrolled` (Ausgabe, Skript, CSP, Ablehnung unzulässiger Scroll-Werte); PHP-Suite 485 Tests grün bis auf den bekannten Altfehler. In der App-Vorschau: sechs Leistungskarten 227 px, Kundenstimmen 232 px, „Warum“-Karten 191 px, gestapelte Karten behalten 18 px. Scroll-Farbe im Kopfbereich-Editor über das Farbfeld gesetzt, in der Vorschau als Regel angekommen und gespeichert. 33 JavaScript-Tests, Twig-Lint aller 49 Vorlagen, PHP-Stilprüfung. Editor-Browserprüfung vollständig erfolgreich, einschließlich aller axe-Prüfungen.
**Veröffentlicht:** Commit `3785d3d` und Tag `v1.11.1` auf `origin`; [Release 1.11.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.1) mit ZIP (3547 Dateien, 4,7 MB), SHA-256-Datei und `install.php` als neuestes stabiles Release angelegt. Anhänge anonym heruntergeladen und bytegenau mit dem lokalen Build verglichen; SHA-256 `970a12bc…6ff306`.
## Kant-IT-Startseite: Einblendungen nach Flatsome-Muster in `var/kantit.db` (14.09.2026)
**Umgesetzt:** Auf Umuts Wunsch das Animationsmuster der alten Flatsome-Website (www.kant-it.de: fadeInLeft/Right im Hero, bei „Warum“ und Kontakt, gestaffeltes fadeInUp auf Zahlen, Karten und Fußzeilenspalten) in der lokalen Kant-IT-Datenbank nachgebaut: 55 Blöcke der Startseite umgestellt – Sektionen ohne eigene Einblendung, Hero-Text von links, Slider-Spalte von rechts, Kennzahlen und Karten gestaffelt von unten, „Warum“-Texte und -Karten von links, Kundenstimmen von rechts, Kontaktdaten links und Formular rechts – sowie die vier Fußzeilenspalten gestaffelt von unten. Als Entwurf gespeichert und veröffentlicht (Revision `155e2934…`), Sicherung `var/kantit.db.vor-bewegung-*.bak`. Umut kopiert die Datei selbst auf den Server; `web.kant-it.de` läuft bereits auf 1.11.0 (Fassung, `motion.js`, Hover und Einblendungen live geprüft).
**Geprüft:** `scripts/motion-check.mjs` (Ziel jetzt `#abschluss-cta`, weil die Sektion `#kontakt` keine eigene Einblendung mehr trägt) auf frischer Pilotkopie bei 1600/390 px erfolgreich: 45 Einblendungen gerendert (30 von unten, 11 von links, 4 von rechts), Warten außerhalb des Bildes, einmalige Wiedergabe, Richtungen, Hover, Standardmodus unter „reduce“, `motion-respect`. Keine JavaScript-Fehler in der App-Vorschau.
## 1.11.0 – Editor mit einer Seitenleiste nach Flatsome-Vorbild, Bewegung spielt standardmäßig (14.09.2026)
**Anlass:** Umut fand den Editor mit drei Panels unübersichtlich und kam mit dem Flatsome UX Builder zurecht; außerdem liefen die Einblendungen in seinem Chrome nicht, weil der Browser „Bewegung reduzieren“ meldet, obwohl er das nicht eingestellt hat – andere Websites bewegen sich bei ihm.
**Umgesetzt:** Editor-Gerüst neu (`templates/admin/editor.html.twig`, `editor.css`, `editor.js`): dunkle Kopfzeile mit Gerätewahl in der Mitte, eine Seitenleiste mit den Reitern Elemente/Struktur/Seite und einer Elementansicht (Name, Pfad, Inhalt/Design/Erweitert, Aktionen unten, ‹ zum Elternelement, + zum Einfügen, ✕ zum Schließen), Elementkarten im Raster, Kontextmenü per Rechtsklick in der Vorschau, Escape/Entf-Tasten auch bei Fokus in der Vorschau, „Bearbeiten“ neben dem gewählten Strukturknoten. Panelbreiten und der Werkzeugkasten-Umschalter entfallen. Sichtbarkeit am Ende von Inhalt und Erweitert. Bewegung: Website-Einstellung `motion` (`always` Standard, `respect`), Klasse `motion-respect` auf `<html>`, `motion.js` versteckt wartende Blöcke und blendet sie von unsichtbar ein, zeigt übersprungene Blöcke sofort; Hover-CSS ohne Medienschranke, Überschreibblock nur für `motion-respect`; `slider.js` folgt derselben Regel. ADR 091.
**Geprüft:** `scripts/motion-check.mjs` auf isolierter Pilotkopie (1600/390 px) erfolgreich: Warten außerhalb des Bildes, Start beim Scrollen, einmalige Wiedergabe, Richtungen, gespeicherter Hover, Standard spielt unter emuliertem „reduce“, `motion-respect` unterdrückt und nimmt wieder auf, Editorhinweis nur bei `motion-respect`. In der App-Vorschau mit realen Kant-IT-Daten: Auswahl, Design-Reiter, Kontextmenü, +-Einfügen (Zeile → nur Spalte erlaubt, Spalte → Überschrift), Entf, Strg+Z, Tablet-Ansicht, öffentliche Startseite mit 14 Einblendungen (7 warten, alle sichtbar nach Sprung ans Seitenende), keine JavaScript-Fehler. 33 JavaScript-Tests, Twig-Lint der geänderten Vorlagen, angepasster CSS-Test in `CmsTest`. `scripts/editor-check.mjs` auf die eine Seitenleiste umgestellt (Reiter-Helfer, Bearbeiten im Baum, Flatsome-Modus in der Sandbox) und vollständig erfolgreich, einschließlich aller axe-Prüfungen (Desktop- und Mobil-Editor, Mediathek, Verwaltungsseiten), Entwurf/Veröffentlichung, Konflikt und Einwilligung.
**Bekannt / Grenzen:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt bereits ohne diese Änderungen fehl (Menülink `Start` fehlt) – nicht Teil dieses Schritts. Kein neuer Lighthouse-Lauf. Die Ursache der Browsermeldung in Umuts Chrome bleibt offen; die Website hängt nicht mehr davon ab.
**Veröffentlicht:** Commit `a1f8b90` und annotierter Tag `v1.11.0` auf `origin`; [Release 1.11.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.0) mit ZIP (3549 Dateien, 4,7 MB), SHA-256-Datei und `install.php` als neuestes stabiles Release über die API angelegt. Alle drei Anhänge anonym unter `releases/download/v1.11.0/` heruntergeladen und bytegenau mit dem lokalen Build verglichen; SHA-256 `49338e77…63ca56` stimmt mit der Datei und dem Installer überein. Die lokale Startkonfiguration `.claude/launch.json` für die Pilotkopie ist nicht committed.
## 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.
**Geprüft:** 28 PHP-Tests / 347 Assertions für Archiv- und Git-Updater, Cachepfad, Systemverwaltung und Installation erfolgreich. Wiederverwendbarer Paket-HTTP-Test reproduziert 1.10.0 → 1.10.1 mit 500 sowie den erfolgreichen Wiederaufnahmeweg (`var/archive-check-f00fc15412`). Mit dem echten Zielpaket 1.10.2: Übergang aus 1.10.1 weiterhin einmalig POST 500, danach System 200, Abschluss 302, öffentlich 200 (`var/archive-check-316de9113a`). Korrigierter Paket-Updater mit ausschließlich synthetischer vorheriger Versionsnummer: POST 302, System 200, Abschluss 302, öffentlich 200, keine fehlenden Cachedienste (`var/archive-check-8637d9310e`). Sitzung, Wartungszustand, Sicherung, Versionswechsel und unveränderte Originaldatenbank geprüft. Produktionscontainer, Cache-Warmup, PHP-Stil und JavaScript-Syntax erfolgreich.
**Veröffentlicht:** Commit `7c1c86b`, Tag `v1.10.2`, [Release 1.10.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.10.2) auf origin und als neuestes stabiles Release geprüft. Paket aus dem exakten Commit, 3543 Archivdateien und 221 Quelldateien bytegenau geprüft; nur Produktionsabhängigkeiten. Frischer Installer HTTP 200, Container und Cache-Warmup auch im Release erfolgreich. ZIP, Prüfsumme und Installer anonym heruntergeladen und mit den lokalen Artefakten verglichen. ZIP-SHA-256: `733f591c143e9d98d4044f37dabce93086542e4db2512b1d4f051617d8835711`.
**Übergabe / Grenzen:** 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. Genau dieser Weg ist mit dem Releasepaket geprüft. Kein Zugriff auf Produktionsprotokolle und keine Produktionsänderung durch diese Diagnose; Startseite und Anmeldung bei der Prüfung HTTP 200. Kein neuer Lighthouse- oder visueller Gesamtlauf. Die während der Arbeit extern geänderte `.claude/launch.json` bleibt unberührt und uncommitted.
## 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. Die PHP-Gesamtprüfung (481 Tests / 3858 Assertions) zeigte einen von CRLF/LF abhängigen Quelltextvergleich der Layout-Gruppen; alle anderen Fälle bestanden. Vergleich zeilenumbruchunabhängig korrigiert, anschließend fünf gezielte PHP-Tests / 29 Assertions erfolgreich. Gesamtsuite wird zur abschließenden Bestätigung mit `php -d max_execution_time=0 vendor/bin/phpunit` wiederholt; der erste Lauf mit lokaler Standardkonfiguration war am 80-Sekunden-Limit abgebrochen.
**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.
**Nächster Schritt:** PHP-Gesamtprüfung abschließen, Paket aus dem exakten Commit bauen, prüfen und veröffentlichen. 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.
**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)
+4
View File
@@ -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",
+4
View File
@@ -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}}
+1
View File
@@ -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}}
+95
View File
@@ -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);
}
+28
View File
@@ -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}
}
+28
View File
@@ -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}
+24 -3
View File
@@ -5,7 +5,7 @@
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
* appear here without touching this file.
*/
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
import { devices, effectiveStyle, hoverStyle, scrolledStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
import { openMedia, createMediaPreview } from './media.js';
import { createColorControl } from './color-control.js';
@@ -104,7 +104,7 @@ function element(tag, className, text) {
return node;
}
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory }) {
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory, getAreaKind = () => 'page' }) {
/**
* The merge key lets a drag collapse into a single undo entry instead of one per pixel.
*
@@ -149,7 +149,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
/** Does this device carry an own value for this property - as a plain value or per side? */
const hasOwn = (block, deviceName, key, hover) => {
const bucket = hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
const bucket = hover === 'scrolled' ? block?.style?.[deviceName]?.scrolled : hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
const value = bucket?.[key];
if (value === undefined) return false;
// A per-side property stores an object; an empty one is a leftover, not a value.
@@ -554,6 +554,27 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
chips: deviceChips(block, key, `${property.label} beim Überfahren`, true),
}));
}
// The scrolled page - only in the header, the one area that changes with scrolling.
if (getAreaKind() === 'header' && (schema.scrollable ?? []).includes(key)) {
const scrolledId = `${id}-scrolled`;
const scrolledValue = scrolledStyle(block, deviceName, key);
let scrolledInherited = current.value || '';
for (const name of Object.keys(devices)) {
if (name === deviceName) break;
scrolledInherited = scrolledStyle(block, name, key) ?? scrolledInherited;
}
const scrolledApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { scrolled: true }), mergeKey, Boolean(mergeKey));
const scrolledControl = property.type === 'color'
? colorControl(scrolledId, scrolledValue ?? '', scrolledApply, `${property.label} beim Scrollen`, scrolledInherited)
: numberControl(scrolledId, scrolledValue ?? '', property, scrolledApply);
body.append(row(scrolledId, `${property.label} beim Scrollen`, scrolledControl, {
own: scrolledValue !== undefined,
inherited: 'Gilt, sobald die Seite um mehr als 40 px gescrollt ist. Vorher gilt der normale Wert - für einen transparenten Kopf dort #ffffff00 eintragen.',
onReset: () => scrolledApply(''),
chips: deviceChips(block, key, `${property.label} beim Scrollen`, 'scrolled'),
}));
}
}
if (group === 'text') {
const notice = contrastNotice(block, deviceName);
+24 -3
View File
@@ -171,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);
};
@@ -254,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) {
@@ -492,7 +507,11 @@ function prune(block) {
* Sets one style value. An empty value removes it, which makes the block fall back to the wider
* device and finally to the theme. Called inside a history transaction like every other change.
*/
export function setStyle(document, id, device, key, value, { side = null, hover = false } = {}) {
export function scrolledStyle(block, device, key) {
return block?.style?.[device]?.scrolled?.[key];
}
export function setStyle(document, id, device, key, value, { side = null, hover = false, scrolled = false } = {}) {
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
@@ -504,6 +523,8 @@ export function setStyle(document, id, device, key, value, { side = null, hover
block.style[device] ??= {};
let target = block.style[device];
if (hover) { target.hover ??= {}; target = target.hover; }
// The scrolled state of the header: same shape as hover, applied once the page has moved.
if (scrolled) { target.scrolled ??= {}; target = target.scrolled; }
if (side !== null) {
if (empty) { if (target[key]) delete target[key][side]; }
@@ -544,7 +565,7 @@ export function copyStyle(document, fromId, toId) {
/** UI relevance only. Existing values remain editable, including on older documents. */
export function relevantProperty(block, key, property) {
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined)) return true;
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined || values.scrolled?.[key] !== undefined)) return true;
const container = isContainer(block);
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
+163 -215
View File
@@ -1,12 +1,15 @@
/* The editor and the media library. Only the editor and the media screen load this; the
management screens get by with admin.css alone. Rules moved out of admin.css unchanged. */
management screens get by with admin.css alone.
The layout follows the Flatsome UX Builder: a dark bar on top, one sidebar on the left that
shows one thing at a time, and the page itself filling the rest. */
dialog{max-width:450px;width:calc(100% - 40px);padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
dialog::backdrop{background:#172e2966}
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.media-launch{margin-top:12px;white-space:normal}
/* ---- media library dialog (unchanged) ---------------------------------------------------- */
.media-launch{margin-top:14px;white-space:normal}
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
.media-heading h2{margin:0}
@@ -30,107 +33,154 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.media-dialog> .panel-hint{margin-top:20px}
.media-details>p{font-size:12px}
@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
.icon-button,.block-actions button,.viewport-controls button{display:inline-flex;align-items:center;justify-content:center;gap:12px;border:1px solid var(--line);border-radius:7px;padding:11px 15px;text-decoration:none;font-size:12px;font-weight:600;background:white;color:var(--ink);white-space:nowrap;min-height:40px}
.editor-message{padding:14px 18px;border-radius:7px;line-height:1.6;font-size:13px}
.editor-body{overflow:hidden}
#editor{height:100dvh;display:flex;flex-direction:column}
.editor-header{display:flex;align-items:center;gap:20px;min-height:76px;padding:13px 20px;border-bottom:1px solid var(--line);background:white;z-index:1}
.back-link{display:flex;gap:9px;text-decoration:none;font-size:12px;padding-right:20px;border-right:1px solid var(--line)}
.editor-page-name{min-width:0;flex:1;display:grid;gap:5px}
.editor-page-name strong{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.editor-page-name>span{font-size:10px;color:var(--muted)}
.history-actions,.save-actions{display:flex;align-items:center;gap:8px}
.icon-button{font-size:21px;min-width:36px;padding:3px 9px}
.save-actions>span{font-size:10px;color:var(--muted);max-width:130px;margin-right:8px}
.editor-layout{display:grid;grid-template-columns:258px minmax(200px,1fr) 280px;flex:1;min-height:0}
.left-panel,.right-panel{background:white;overflow:auto}
.left-panel{border-right:1px solid var(--line)}
.right-panel{border-left:1px solid var(--line)}
.panel-section{padding:23px 18px;border-bottom:1px solid var(--line)}
.panel-section .eyebrow{margin-bottom:10px}
.panel-section h2{font-size:14px;margin-bottom:12px}
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
.panel-title{display:flex;justify-content:space-between;align-items:center}
.panel-title h2{margin-bottom:0}
.panel-title+.panel-hint{margin-top:10px}
/* ---- shell ------------------------------------------------------------------------------- */
html:has(>.editor-body){overflow:hidden;height:100%}
.editor-body{overflow:hidden;height:100dvh}
#editor{height:100dvh;display:flex;flex-direction:column;background:#e3e7e1}
.editor-header{display:flex;align-items:center;gap:12px;height:52px;padding:0 12px;background:var(--ink);color:#fff;flex-shrink:0;z-index:5}
.back-link{display:flex;gap:7px;align-items:center;color:#cfd9d3;text-decoration:none;font-size:12px;font-weight:600;padding:8px 14px 8px 4px;border-right:1px solid #ffffff22;white-space:nowrap}
.back-link:hover{color:#fff}
.editor-page-name{min-width:0;width:230px;display:grid;gap:1px}
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0;color:#fff}
.editor-page-name>span{font-size:11px;color:#9fb1a7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.icon-button{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;min-height:0;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:inherit;text-decoration:none;font-size:14px;line-height:1}
.icon-button svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.editor-header .icon-button{color:#d5ded8}
.editor-header .icon-button:hover:not(:disabled){background:#ffffff1a;color:#fff}
.editor-header .icon-button[aria-pressed=true]{background:#fff;color:var(--ink)}
.viewport-controls{display:flex;gap:2px;margin:0 auto;padding:3px;background:#ffffff14;border-radius:9px}
.viewport-controls button{display:inline-flex;align-items:center;gap:7px;border:0;background:transparent;color:#c9d4cd;padding:0 11px;min-height:30px;border-radius:6px;font-size:12px;font-weight:600;white-space:nowrap}
.viewport-controls button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.viewport-controls button:hover:not(:disabled){color:#fff}
.editor-header .viewport-controls button[aria-pressed=true]{background:#fff;color:var(--ink)}
.history-actions{display:flex;gap:2px}
.save-actions{display:flex;align-items:center;gap:8px}
.save-actions>span{font-size:11px;color:#9fb1a7;max-width:150px;text-align:right;line-height:1.3}
.editor-header .secondary{background:transparent;color:#fff;border:1px solid #ffffff4d;font-size:12px;padding:0 13px;min-height:34px;white-space:nowrap}
.editor-header .secondary:hover:not(:disabled){background:#ffffff1a;border-color:#ffffff99}
.editor-header .primary{font-size:12px;padding:0 15px;min-height:34px;white-space:nowrap}
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 16px;font-size:12px;line-height:1.6}
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
/* The single row is capped at the space left under the header, so the sidebar and the canvas
scroll inside themselves instead of stretching the workspace. */
.editor-layout{display:grid;grid-template-columns:330px minmax(0,1fr);grid-template-rows:minmax(0,1fr);flex:1;min-height:0}
/* ---- sidebar ----------------------------------------------------------------------------- */
.sidebar{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:#fff;border-right:1px solid var(--line)}
.sidebar-head{flex:none;border-bottom:1px solid var(--line);background:#fff}
.sidebar-nav{display:flex}
.sidebar-nav [role=tab]{flex:1;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--muted);font-size:12px;font-weight:700;letter-spacing:.2px;padding:0 6px;min-height:46px;border-radius:0}
.sidebar-nav [role=tab]:hover:not(:disabled){color:var(--ink);background:#f7f9f5}
.sidebar-nav [aria-selected=true]{color:var(--ink);border-bottom-color:var(--accent)}
.sidebar-element{display:flex;align-items:center;gap:4px;padding:7px 8px 7px 6px;border-top:1px solid var(--line);background:#f7f9f5}
.sidebar-element .icon-button{color:var(--ink);flex:none}
.sidebar-element .icon-button:hover:not(:disabled){background:#eef2ea}
.sidebar-element-name{flex:1;min-width:0;padding:0 4px}
.sidebar-element-name h2{margin:0;font-size:14px;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}
.breadcrumbs{display:flex;flex-wrap:wrap;align-items:center;gap:1px;min-height:0;line-height:1.3}
.breadcrumbs:empty{display:none}
.crumb{border:0;background:none;padding:0;min-height:0;font-size:10.5px;color:var(--muted);cursor:pointer;white-space:nowrap}
.crumb:hover{color:var(--accent);text-decoration:underline}
.crumb+.crumb::before{content:'›';margin:0 4px;color:#b7c2bb}
.property-tabs{display:flex;gap:4px;padding:0 8px 8px}
.property-tabs button{flex:1;min-width:0;border:1px solid var(--line);background:#f5f7f2;color:var(--ink);border-radius:7px;padding:0 6px;min-height:32px;font-size:12px;font-weight:600}
.property-tabs button:hover:not(:disabled){border-color:#b9c6bd}
.property-tabs [aria-pressed=true]{background:var(--ink);color:#fff;border-color:var(--ink)}
.sidebar-body{flex:1;min-height:0;overflow:auto;overflow-x:hidden;padding:14px 14px 28px;container-type:inline-size}
.sidebar-view>:first-child{margin-top:0}
.sidebar-foot{flex:none;display:flex;gap:6px;align-items:center;padding:10px 12px;border-top:1px solid var(--line);background:#fff}
.sidebar-foot .icon-button{border-color:var(--line);color:var(--ink)}
.sidebar-foot .icon-button:hover:not(:disabled){background:#eef2ea}
.sidebar-foot .secondary{flex:1;font-size:12px;padding:0 8px;min-height:34px;white-space:nowrap}
.sidebar-foot .danger-text{color:#943a2e}
.sidebar-note{flex:none;font-size:11px;line-height:1.5;color:#873e24;background:#fff4ec;border-top:1px solid #e6c3b2;margin:0;padding:10px 14px}
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0 0 12px}
.panel-note{font-size:11px;color:var(--muted);line-height:1.7;padding:12px 0}
.sidebar label{font-size:12px;margin:16px 0 6px}
.sidebar input,.sidebar select,.sidebar textarea{font-size:12px;border-radius:7px;min-width:0;max-width:100%}
.sidebar textarea{min-height:120px}
.sidebar select{padding-inline:7px;text-overflow:ellipsis}
.sidebar .field-help{font-size:11px;line-height:1.5}
.sidebar .full-width{width:100%}
.sidebar .secondary,.sidebar .primary{font-size:12px;min-height:36px}
/* ---- element library --------------------------------------------------------------------- */
#library-search,#tree-search{font-size:12px;min-height:38px;padding:8px 11px;margin:0 0 4px}
#library-found{margin:0 0 8px;min-height:0}
#library-found:empty{display:none}
.library-group+.library-group{margin-top:18px}
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:16px 0 8px}
.library-group:first-child .library-group-title{margin-top:8px}
.library-group-hint{font-size:11px;line-height:1.5;color:var(--muted);margin:-4px 0 8px}
.library-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
.library-buttons button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;min-height:74px;padding:10px 5px 9px;border:1px solid var(--line);border-radius:9px;background:#fbfcfa;color:var(--ink);font-size:11px;line-height:1.2;text-align:center;overflow-wrap:anywhere;min-width:0}
.library-preview{display:grid;place-items:center;width:40px;height:27px;color:var(--accent)}
.library-preview svg{width:40px;height:27px}
.library-name{min-width:0}
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff;box-shadow:0 2px 10px #1b2a2414}
.library-buttons button:focus-visible{outline:2px solid #b55b12;outline-offset:1px}
.library-buttons button:disabled{opacity:.35;cursor:not-allowed}
.library-buttons button:disabled .library-preview{color:var(--muted)}
#library button[draggable=true]{cursor:grab}
#library button[draggable=true]:active{cursor:grabbing}
@container(max-width:260px){.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}}
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:8px;padding:9px 11px;font-size:11.5px;line-height:1.5;margin:0 0 12px}
.insert-hint .text-button{margin-left:4px}
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
.preset-library{display:grid;gap:6px}
.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#fbfcfa;color:var(--ink);font-size:12px}
.preset-library button:hover:not(:disabled){border-color:var(--accent);background:#fff}
.preset-library span{font-size:16px;color:var(--accent)}
/* ---- structure --------------------------------------------------------------------------- */
.tree-tools{display:flex;flex-wrap:wrap;gap:2px;margin:4px 0 8px}
.tree-tools .text-button{font-size:11px;padding:4px 7px;min-height:26px}
.structure-list{list-style:none;margin:0;padding:0}
.structure-list .structure-list{padding-left:13px;border-left:1px solid #e5e9e3;margin-left:8px}
.tree-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;border:1px solid transparent;background:none;color:#42584b;border-radius:5px;padding:9px 7px;font-size:11px;min-height:34px}
.structure-list .structure-list{padding-left:12px;border-left:1px solid #e5e9e3;margin-left:9px}
.structure-list li{position:relative}
.tree-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;border:1px solid transparent;background:none;color:#42584b;border-radius:6px;padding:8px 7px 8px 24px;font-size:12px;min-height:34px}
.tree-item span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tree-item:hover{background:#f3f6ef}
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600}
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600;padding-right:84px}
.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}
.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px;min-height:0;padding:0}
.tree-edit{position:absolute;top:5px;right:4px;z-index:1;min-height:0;padding:4px 8px;border:1px solid #c5d89a;border-radius:6px;background:#fff;color:var(--accent);font-size:11px;font-weight:700}
.tree-edit:hover{background:var(--accent);color:#fff}
.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}
.block-actions .danger-text{color:#943a2e}
.canvas-panel{min-width:0;display:flex;flex-direction:column;background:#e9ede7;overflow:hidden}
.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 20px;background:#f6f8f2;border-bottom:1px solid var(--line);min-height:59px}
.canvas-label{font-size:9px;font-weight:700;letter-spacing:1.2px;color:#57655d}
.viewport-controls{display:flex;gap:3px;background:#e8eee1;border-radius:7px;padding:3px}
.viewport-controls button{padding:5px 9px;font-size:10px;min-height:28px;border:0;background:transparent}
.viewport-controls [aria-pressed=true]{background:white;box-shadow:0 1px 3px #00000012}
.canvas-toolbar>a{text-decoration:none;font-size:18px}
.canvas-scroll{flex:1;min-height:0;overflow:auto;padding:22px;display:flex;justify-content:center;align-items:flex-start}
#preview{background:white;border:0;box-shadow:0 3px 20px #203b3410;min-height:600px;height:100%;flex-shrink:0}
.viewport-desktop{width:100%}
.viewport-tablet{width:768px;max-width:100%}
.viewport-mobile{width:390px;max-width:100%}
.preview-status{font-size:10px;padding:9px 20px;color:#52614f;margin:0;min-height:30px;background:#f3f5ed}
.right-panel label{font-size:11px}
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px}
.right-panel textarea{min-height:150px}
.panel-note{font-size:11px;color:var(--muted);line-height:1.7;padding:20px}
/* ---- canvas ------------------------------------------------------------------------------ */
.canvas-panel{min-width:0;min-height:0;display:flex;flex-direction:column;background:#e3e7e1;overflow:hidden}
.canvas-scroll{flex:1;min-height:0;overflow:auto;padding:16px}
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
#preview-stage{position:relative;margin:0 auto;background:#fff;box-shadow:0 8px 30px #1b2a2418;border-radius:6px;overflow:hidden}
#preview{position:absolute;top:0;left:0;border:0;background:#fff;transform-origin:top left;max-width:none;min-height:0}
.preview-status{flex:none;font-size:11px;padding:6px 14px;color:#52614f;margin:0;background:#f5f7f2;border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.resizing-panels iframe{pointer-events:none}
/* ---- context menu ------------------------------------------------------------------------ */
.context-menu{position:fixed;z-index:60;min-width:210px;padding:5px;background:#fff;border:1px solid var(--line);border-radius:9px;box-shadow:0 12px 40px #1b2a2433;display:grid}
.context-menu button{border:0;background:transparent;text-align:left;padding:8px 11px;min-height:0;border-radius:6px;font-size:12px;color:var(--ink);white-space:nowrap}
.context-menu button:hover,.context-menu button:focus-visible{background:#eef2ea;outline:0}
.context-menu .danger-text{color:#943a2e}
.context-menu .danger-text:hover{background:#fbeae6}
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 20px;font-size:12px}
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 20px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
@media(max-width:1150px){.editor-layout{grid-template-columns:215px minmax(200px,1fr) 240px}.editor-header{gap:12px;padding-inline:14px}.save-actions>span{display:none}.panel-section{padding:18px 14px}.canvas-scroll{padding:12px}.canvas-label{display:none}}
@media(max-width:850px){.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}.editor-header{flex-wrap:wrap}.editor-page-name{min-width:180px}.save-actions{width:100%;justify-content:flex-end}.save-actions>span{display:block;margin-right:auto}.editor-layout{display:flex;flex-wrap:wrap}.canvas-panel{order:0;width:100%;height:680px}.left-panel{order:1;width:45%;max-height:650px}.right-panel{order:2;width:55%;max-height:650px}.canvas-label{display:block}.editor-header .back-link{padding-right:12px}.editor-message{font-size:11px}.editor-layout .panel-hint{font-size:10px}}
.left-panel{display:flex;flex-direction:column;overflow:hidden}
.left-panel>.panel-section:first-child{max-height:45%;overflow:auto;flex-shrink:0}
.structure-section{display:flex;flex-direction:column;flex:1;min-height:0}
.structure-section #structure{overflow:auto;flex:1;min-height:90px;padding:3px}
.structure-section .block-actions{flex-shrink:0;background:white}
.structure-section .panel-hint{margin:8px 0}
.search-label{font-size:11px;margin:12px 0 6px}
#tree-search{font-size:11px;padding:9px;min-height:36px}
.tree-tools{display:flex;flex-wrap:wrap;gap:3px;margin-top:5px}
.tree-tools .text-button{font-size:10px;padding:5px}
.structure-list li{position:relative}
.tree-item{padding-left:23px}
.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px}
.preset-heading{font-size:12px;margin:22px 0 8px}
.preset-library{display:grid;gap:6px}
.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:9px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
.preset-library button:hover:not(:disabled){border-color:var(--accent)}
.preset-library span{font-size:18px}
.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}
/* ---- properties -------------------------------------------------------------------------- */
.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-scope{font-size:12px;font-weight:600;line-height:1.6;margin:0 0 8px;padding:9px 10px;border-left:3px solid var(--accent);background:#f1f5e7;border-radius:0 7px 7px 0}
.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)}
.breadcrumbs .text-button{font-size:10px;white-space:normal}
.breadcrumbs button+button:before{content:'›';color:var(--muted)}
.breadcrumbs [aria-current=location]{font-weight:700;background:#e5edda}
.canvas-scroll{display:block}
.canvas-scroll #preview-stage{position:relative;margin:0 auto}
#preview{position:absolute;top:0;left:0;transform-origin:top left;max-width:none;min-height:0}
.right-panel select{padding-inline:7px;text-overflow:ellipsis}
.right-panel .panel-section{min-width:0}
@media(max-width:850px){.left-panel,.right-panel{height:650px}.left-panel>.panel-section:first-child{max-height:40%}.structure-section{padding:12px 7px}.tree-item{font-size:10px;gap:3px}.tree-icon{min-width:10px}.structure-list .structure-list{padding-left:5px;margin-left:4px}.block-actions button{padding:6px;white-space:normal}.property-tabs button{padding:9px 4px;font-size:11px}.canvas-scroll{padding:12px}.right-panel label{line-height:1.5}.tree-tools .text-button{white-space:normal}}
.design-group{border:1px solid var(--line);border-radius:9px;margin-top:10px;background:#fff}
.design-group>summary{cursor:pointer;padding:12px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
.design-group>summary::after{content:'+';font-size:17px}
.design-group{border:1px solid var(--line);border-radius:10px;margin-top:10px;background:#fff}
.design-group>summary{cursor:pointer;padding:11px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
.design-group>summary::-webkit-details-marker{display:none}
.design-group>summary::after{content:'+';font-size:17px;color:var(--muted)}
.design-group[open]>summary{border-bottom:1px solid var(--line)}
.design-group[open]>summary::after{content:'–'}
.design-group-body{padding:6px 14px 16px}
.design-group-body{padding:4px 14px 16px}
.design-group-body>label:first-child{margin-top:10px}
.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}
@@ -139,38 +189,11 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.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{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}
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
.length-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:6px}
.length-control input,.length-control select{font-size:12px;min-height:38px}
.length-control.is-inherited input{color:var(--muted)}
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
.length-mode{grid-column:1/-1}
.number-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:8px;align-items:center}
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
.number-control input[type=number]{font-size:12px;min-height:38px}
.side-wrap{margin-bottom:10px}
@@ -187,7 +210,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
.side-unit-select{flex:1;min-width:0}
/* The device chips on a single option: which devices carry an own value, and switch to one. */
.device-chips{display:flex;gap:2px;margin-left:auto}
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
@@ -196,16 +218,14 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
.design-side-head{margin-bottom:5px}
.design-side-head .design-group-label{margin:0}
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
.scrubbable{cursor:ew-resize}
.scrubbable:focus{cursor:text}
.design-actions{display:grid;gap:8px;margin-top:22px}
.design-actions button{width:100%}
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
#design-search{font-size:12px;min-height:36px;padding:9px}
.search-label{font-size:11px;margin:12px 0 6px}
.media-control{display:grid;gap:8px}
.media-chosen{margin:0;font-size:11px;color:var(--muted)}
.design-reuse{display:grid;gap:8px;margin-top:24px;padding-top:18px;border-top:1px solid var(--line)}
@@ -218,9 +238,10 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.rt-button:hover{background:#eef2e7;border-color:var(--line)}
.rt-toggle{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.5px}
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
.rt-link{display:flex;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
.rt-link{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
.rt-link input{font-size:11px;min-height:30px}
.rt-link{flex-wrap:wrap;align-items:center}.rt-link>input{flex:1 0 100%;min-width:0}.rt-link .check-label{margin:0;font-size:12px}.rt-link input[type=checkbox]{min-height:0;width:auto}.selected-media-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.selected-media-list .selected-media-preview img{height:90px}.selected-media-list .text-button{font-size:11px;padding:6px}
.rt-link>input{flex:1 0 100%;min-width:0}.rt-link .check-label{margin:0;font-size:12px}.rt-link input[type=checkbox]{min-height:0;width:auto}
.selected-media-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.selected-media-list .selected-media-preview img{height:90px}.selected-media-list .text-button{font-size:11px;padding:6px}
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
@@ -228,27 +249,9 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.rt-source{min-height:150px;border:0;border-radius:0;font-family:ui-monospace,monospace;font-size:12px;line-height:1.6}
.own-code{font-family:ui-monospace,monospace;font-size:12px;line-height:1.6;min-height:110px}
.own-code[readonly]{background:#f4f5f1;color:var(--muted)}
/* Bringing the editor chrome in line with the new administration: same radii, same restraint,
and a few sizes that were simply too small to read comfortably. The layout is unchanged. */
.editor-header{min-height:70px}
.back-link{font-size:12px;font-weight:600;gap:7px;align-items:center}
.editor-page-name h1{font-size:14px}
.panel-section h2{font-size:13px;letter-spacing:0}
.panel-section .eyebrow{font-size:9px;letter-spacing:1.4px}
.tree-item{font-size:12px;border-radius:6px}
.right-panel label{font-size:12px}
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px;border-radius:7px}
.property-tabs{padding:3px;border-radius:9px}
.property-tabs button{border-radius:7px;font-size:12px}
.viewport-controls{border-radius:8px}
.viewport-controls button{border-radius:6px;font-size:11px}
.design-group{border-radius:10px}
.design-scope{border-radius:0 7px 7px 0}
#preview{box-shadow:0 6px 28px #1b2a2414}
.canvas-panel{background:#e7ebe5}
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
.icon-button{border-radius:8px}
#design-search{font-size:12px;min-height:36px;padding:9px}
.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1}
.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px}
.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424}
/* --------------------------------------------------------------------------------------------
The working layer above the preview.
@@ -262,8 +265,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.ov-box{position:absolute;pointer-events:none}
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:var(--ring,#b55b12);color:#fff;font:700 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
.ov-label{background:#5c6b62;font-weight:600}
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:#5c6b62;color:#fff;font:600 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
@@ -277,68 +279,14 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
/* While something is being dragged the outlines would only add noise. */
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
#library button[draggable=true]{cursor:grab}
#library button[draggable=true]:active{cursor:grabbing}
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:7px;padding:9px 11px;font-size:11px;line-height:1.5;margin:0 0 12px}
.insert-hint .text-button{margin-left:4px}
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
/* The reduced-motion setting is honoured: nothing here animates by itself anyway, but the
fade on the insert points is a transition and is switched off with the rest. */
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
/* --------------------------------------------------------------------------------------------
The toolbox, grouped the way a page is built.
The first group is the scaffolding, the rest goes inside it. What does not fit where the next
element would land is disabled and carries the reason in its title, so the rule is learned
rather than merely enforced.
-------------------------------------------------------------------------------------------- */
.library-group + .library-group{margin-top:16px}
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:0 0 7px}
.library-group-hint{font-size:10px;line-height:1.5;color:var(--muted);margin:-3px 0 8px}
.library-buttons{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.library-buttons button{display:flex;align-items:center;gap:8px;text-align:left;min-height:44px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px;line-height:1.25}
.library-preview{flex:none;display:grid;place-items:center;width:34px;height:23px;color:var(--accent);opacity:.85}
.library-buttons button:disabled .library-preview{color:var(--muted);opacity:.6}
.library-name{min-width:0}
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff}
.library-buttons button:disabled{opacity:.4;background:transparent;cursor:not-allowed}
.library-buttons button[data-row]{grid-column:span 2}
@media(max-width:1150px){.library-buttons{grid-template-columns:1fr}.library-buttons button[data-row]{grid-column:span 1}}
#library-search{font-size:11px;min-height:36px;padding:9px;margin-bottom:4px}
#library-found{margin:0 0 10px;min-height:0}
/* Flexible workspace: both panels use their full width, with accessible resize handles. */
.editor-layout{grid-template-columns:var(--panel-left,280px) 6px minmax(280px,1fr) 6px var(--panel-right,320px)}
.left-panel,.right-panel{min-width:0;overflow-x:hidden}
.left-panel{container-type:inline-size}
.left-panel>.section-launch{flex:none;padding:12px;border-bottom:1px solid var(--line)}
.section-launch button{white-space:normal;font-size:12px;min-height:40px}
.section-launch .field-help{margin:6px 0 0}
.left-panel>.panel-section:not(.structure-section){max-height:55%;overflow:auto;flex-shrink:0}
.panel-section{padding:16px 12px}
.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}
.library-buttons button{min-width:0;white-space:normal;overflow-wrap:anywhere;gap:6px}
.library-name{overflow-wrap:anywhere}
.library-preview{width:28px}
@container(max-width:270px){.library-buttons{grid-template-columns:minmax(0,1fr)}.library-buttons button[data-row]{grid-column:auto}}
.canvas-scroll{padding:0}
.panel-resizer{background:#e4e9df;cursor:col-resize;touch-action:none;position:relative;z-index:4}
.panel-resizer::after{content:'';position:absolute;top:45%;left:1px;width:4px;height:36px;border-radius:4px;background:#7b8d80}
.panel-resizer:hover,.panel-resizer:focus-visible{background:#b7cbbd;outline:2px solid var(--accent);outline-offset:-2px}
.resizing-panels{cursor:col-resize;user-select:none}
.resizing-panels iframe{pointer-events:none}
.length-control,.number-control{grid-template-columns:minmax(0,1fr) 74px}
.right-panel input,.right-panel select,.right-panel textarea{min-width:0;max-width:100%}
.length-mode{grid-column:1/-1}
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
@media(max-width:850px){.panel-resizer{display:none}.editor-layout{display:flex}.left-panel,.right-panel{width:50%}.canvas-scroll{padding:0}.left-panel>.panel-section:not(.structure-section){max-height:50%}}
@media(max-width:560px){.left-panel,.right-panel{width:100%;height:620px;max-height:620px}.canvas-toolbar{padding-inline:8px}.canvas-label{display:none}}
.canvas-toolbar{flex-wrap:wrap;gap:8px}.canvas-toolbar #preview-motion{white-space:nowrap;min-height:36px}
.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1}
.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px}
.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424}
/* ---- narrow screens ---------------------------------------------------------------------- */
@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{display:none}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}}
@media(max-width:850px){
.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}
.editor-header{flex-wrap:wrap;height:auto;padding:8px 10px;gap:8px}.editor-page-name{flex:1;width:auto}.viewport-controls{order:6;margin:0}.save-actions{margin-left:auto}
.editor-layout{display:flex;flex-direction:column}
.canvas-panel{height:62vh;flex:none}.sidebar{height:62vh;flex:none;border-right:0;border-top:1px solid var(--line)}
.library-buttons{grid-template-columns:repeat(4,minmax(0,1fr))}
}
+245 -91
View File
@@ -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,12 +32,46 @@ const collapsed = new Set();
let renderedProperties = null;
let revealAfterPreview = null;
let previewPlayback = false, stopPlayback = () => {};
const motionPreference = window.matchMedia('(prefers-reduced-motion: reduce)');
// Worth a note only when the website actually follows the browser (Einstellungen → Bewegung);
// the preview carries that decision as a class on its <html>.
const showMotionStatus = () => {
const respects = $('#preview').contentDocument?.documentElement.classList.contains('motion-respect') ?? false;
$('#motion-status').hidden = !(motionPreference.matches && respects);
};
motionPreference.addEventListener('change', showMotionStatus);
showMotionStatus();
// ---------------------------------------------------------------------------------------------
// The sidebar shows one thing at a time.
//
// `elements` is the library, `structure` the tree, `page` the page settings and `element` the
// options of the selected block. The last one is not a tab: it appears when something is
// selected and gives way to the library when the selection is dropped - the Flatsome rhythm of
// "pick, edit, back to the list".
// ---------------------------------------------------------------------------------------------
let view = 'elements';
function setView(name) {
view = name;
const sidebar = $('.sidebar');
sidebar.dataset.view = name;
const showsProperties = name === 'element' || name === 'page';
for (const section of sidebar.querySelectorAll('.sidebar-view')) section.hidden = section.dataset.view !== (showsProperties ? 'element' : name);
// The tabs stay in reach while an element is open, so the library and the tree are one click
// away; none of them is marked then, because the element view is not a tab.
for (const tab of sidebar.querySelectorAll('.sidebar-nav [role=tab]')) tab.setAttribute('aria-selected', String(tab.dataset.view === name));
$('.sidebar-element').hidden = name !== 'element';
$('.property-tabs').hidden = name !== 'element';
$('.sidebar-foot').hidden = name !== 'element';
$('.sidebar-body').scrollTop = 0;
}
function playPreview() {
stopPlayback(); stopPlayback = () => {};
const button = $('#preview-motion');
button.setAttribute('aria-pressed', String(previewPlayback));
button.textContent = previewPlayback ? 'Bewegung stoppen' : 'Bewegung testen';
button.title = previewPlayback ? 'Bewegung in der Vorschau stoppen' : 'Bewegung in der Vorschau abspielen';
button.setAttribute('aria-label', button.title);
const doc = $('#preview').contentDocument;
if (previewPlayback && doc?.body) {
const stopMotion = initMotion(doc), stopSliders = initSliders(doc);
@@ -80,20 +116,19 @@ function controls() {
$('#undo').disabled = !ready || busy || !history.past.length;
$('#redo').disabled = !ready || busy || !history.future.length;
const current = ready ? locate(history.document.blocks, selected) : null;
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent']) $(`#${id}`).disabled = !current || busy;
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy;
if (current) {
$('#move-up').disabled ||= current.index === 0;
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
$('#outdent').disabled ||= !current.parent;
$('#go-parent').disabled ||= !current.parent;
}
updateLibrary();
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
document.querySelectorAll('[data-panel],[data-viewport],.sidebar-nav [role=tab],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
$('[data-panel="design"]').disabled ||= !current;
$('#select-page').disabled = !ready || busy;
$('#add-root-section').disabled = !ready || busy;
$('#open-media-library').disabled = !ready || busy;
$('#preview-motion').disabled = !ready || busy;
}
@@ -124,6 +159,20 @@ function renderTree() {
const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = blockIcon(block);
const text = document.createElement('span'); text.textContent = displayText;
button.append(icon, text); item.append(button);
if (block.layout?.visibility && block.layout.visibility !== 'all') {
const badge = document.createElement('small'); badge.className = 'tree-visibility';
badge.textContent = VISIBILITY[block.layout.visibility];
button.title = badge.textContent; button.append(badge);
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText} – ${badge.textContent}`);
}
if (block.id === selected) {
// In the tree a click only selects, so the tree stays open while moving through the
// page. Editing is one step further - this button, or a double click.
const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'tree-edit';
edit.dataset.editBlock = block.id; edit.textContent = 'Bearbeiten';
edit.setAttribute('aria-label', `${blockLabel(block)} bearbeiten`);
item.append(edit);
}
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
list.append(item);
}
@@ -141,15 +190,31 @@ function choose(id, scroll = true) {
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
if (!selected) propertyPanel = 'content';
history.mergeKey = null;
syncView();
renderTree(); renderProperties(); controls(); highlight(scroll);
}
/**
* Selecting opens the element; dropping the selection goes back to the library. The tree is
* the exception: there the selection only moves the highlight, editing is one more step.
*/
function syncView() {
if (selected && view !== 'structure') setView('element');
else if (!selected && view === 'element') setView('elements');
}
function highlight(scroll = false) {
const doc = $('#preview').contentDocument;
if (!doc) return;
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
const element = selected ? doc.getElementById(selected) : null;
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
if (element) {
element.classList.add('editor-selected');
// Scroll the preview window only. scrollIntoView would also drag the editor document along
// with it, and the whole workspace would slide out from under the header.
const win = doc.defaultView, box = element.getBoundingClientRect();
if (scroll && win && (box.top < 0 || box.bottom > win.innerHeight)) win.scrollTo({ top: win.scrollY + box.top - 40 });
}
overlay?.sync();
}
@@ -157,15 +222,15 @@ function renderProperties() {
const block = locate(history.document.blocks, selected)?.block;
if (!block) propertyPanel = 'content';
const context = `${selected ?? 'page'}:${propertyPanel}`;
if (context !== renderedProperties) $('.right-panel').scrollTop = 0;
if (context !== renderedProperties) $('.sidebar-body').scrollTop = 0;
renderedProperties = context;
$('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen';
const panel = $('#properties'); panel.replaceChildren();
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
// The way up, as a trail above the name: page, then every container the element sits in.
$('#breadcrumbs').replaceChildren();
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)]) {
const button = document.createElement('button'); button.className = 'text-button'; button.textContent = item.type ? blockLabel(item) : 'Seite';
button.setAttribute('aria-current', item.id === selected ? 'location' : 'false');
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)].slice(0, -1)) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'crumb'; button.textContent = item.type ? blockLabel(item) : 'Seite';
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
}
if (block && isPluginBlock(block)) {
@@ -176,8 +241,33 @@ function renderProperties() {
panel.append(note);
return;
}
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); renderVisibility(block, panel); return; }
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
renderContent(block, panel);
if (block) renderVisibility(block, panel);
}
/** On which devices the element is shown. Part of the content, so it sits at its end. */
function renderVisibility(block, panel) {
const group = document.createElement('details'); group.className = 'design-group';
group.open = (block.layout?.visibility ?? 'all') !== 'all';
const summary = document.createElement('summary'); summary.textContent = 'Sichtbarkeit';
const body = document.createElement('div'); body.className = 'design-group-body';
const label = document.createElement('label'); label.htmlFor = 'block-visibility'; label.textContent = 'Anzeigen';
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 in der Struktur auswählbar.';
select.setAttribute('aria-describedby', help.id);
body.append(label, select, help); group.append(summary, body); panel.append(group);
}
function renderContent(block, panel) {
let imagePreview;
const field = (label, key, value, options = {}) => {
const container = options.container || panel;
@@ -202,7 +292,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.
@@ -212,6 +306,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 });
@@ -248,6 +343,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';
@@ -291,6 +387,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') {
@@ -339,6 +436,7 @@ function renderProperties() {
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, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
@@ -499,6 +597,7 @@ function renderImageWidth(block, panel) {
}
function renderDesign(block, panel) {
if (block.type === 'button' && designSchema) buttonControls(block, panel);
if (designSchema && designPanel) {
designPanel(panel);
} else {
@@ -736,6 +835,10 @@ async function updatePreview() {
}
doc.addEventListener('click', event => {
event.preventDefault();
const menuSummary = event.target.closest('.block-menu summary');
if (menuSummary && !busy && !inlineEdit) {
menuSummary.parentElement.open = !menuSummary.parentElement.open;
}
const insert = event.target.closest('[data-editor-target]');
if (insert && !busy && history) {
stopInlineEdit();
@@ -764,6 +867,21 @@ async function updatePreview() {
const block = locate(history.document.blocks, element.id)?.block;
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
});
// Right click: the element's actions where the pointer is, as in Flatsome.
doc.addEventListener('contextmenu', event => {
const element = event.target.closest('[id]');
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit) return;
event.preventDefault();
choose(element.id, false);
openContextMenu(element.id, event.clientX, event.clientY);
});
doc.addEventListener('mousedown', event => { if (event.button === 0) closeContextMenu(); });
doc.addEventListener('keydown', onKey);
// The preview runs no script, so the scrolled state of the header is marked from here -
// the same rule scrolled.js applies on the public page.
const markScrolled = () => doc.documentElement.classList.toggle('is-scrolled', (doc.defaultView?.scrollY ?? 0) > 40);
doc.defaultView?.addEventListener('scroll', markScrolled, { passive: true });
markScrolled();
doc.addEventListener('submit', event => event.preventDefault());
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
const finish = () => {
@@ -776,6 +894,7 @@ async function updatePreview() {
highlight(revealAfterPreview === selected); revealAfterPreview = null;
markInsertTarget();
frame.style.visibility = '';
showMotionStatus();
playPreview();
$('#preview-status').textContent = loaded
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
@@ -815,6 +934,7 @@ async function updatePreview() {
function changed() {
if (!locate(history.document.blocks, selected)) selected = null;
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
syncView();
renderTree(); renderProperties(); controls(); schedulePreview();
}
@@ -832,6 +952,7 @@ async function loadDesignSchema() {
onDevice: setViewport,
getSelection: () => selected,
getHistory: () => history,
getAreaKind: () => root.dataset.kind,
});
} catch {
// The editor stays usable without it; the panel says so.
@@ -876,14 +997,21 @@ function addElement(type) {
if (busy || !history) return;
const at = pendingInsert;
setPendingInsert(null);
selected = history.change(doc => at
selected = history.change(doc => at && type !== 'section'
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
: insertBlock(doc, type, selected));
: insertBlock(doc, type, at?.parentId ?? 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;
@@ -909,12 +1037,11 @@ function setPendingInsert(target) {
$('#insert-hint').hidden = !target;
markInsertTarget();
if (target) {
if ($('#toolbox').hidden) $('#toggle-library').click();
setView('elements');
$('#library-search').value = ''; filterLibrary();
const name = target.parentId ? $('#preview').contentDocument?.getElementById(target.parentId)?.dataset.editorLabel || blockLabel(locate(history.document.blocks, target.parentId)?.block) : 'Seite';
$('#insert-hint').firstChild.textContent = `Einfügen in ${name}. Wähle jetzt ein Element. `;
$('#library-search').focus({ preventScroll: true });
$('#library-search').scrollIntoView({ block: 'nearest' });
}
}
@@ -953,10 +1080,10 @@ function buildLibrary() {
button.type = 'button'; button.draggable = true;
button.dataset.add = type;
if (columns) button.dataset.row = String(columns);
const name = columns ? `${labels[type]}, ${columns} Spalten` : labels[type];
const name = columns ? `${columns} Spalten` : labels[type];
// What is searched for: the name, the internal type and the group it sits in. Someone
// looking for "slider" should not have to know it is filed under "Gruppen".
button.dataset.search = `${name} ${type} ${category.name}`.toLocaleLowerCase('de');
button.dataset.search = `${name} ${columns ? labels[type] + ' ' : ''}${type} ${category.name}`.toLocaleLowerCase('de');
const picture = document.createElement('span');
picture.className = 'library-preview';
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
@@ -982,9 +1109,10 @@ function updateLibrary() {
for (const button of $('#library').querySelectorAll('button')) {
const type = button.dataset.add;
if (!ready || busy) { button.disabled = true; button.removeAttribute('title'); continue; }
const target = pendingInsert
// A section always has a place - on the page, after the area the insert point sits in.
const target = pendingInsert && type !== 'section'
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
: insertionTarget(history.document, selected, type);
: insertionTarget(history.document, pendingInsert?.parentId ?? selected, type);
button.disabled = !target;
if (!target) {
button.title = locate(history.document.blocks, selected)?.block.type === 'accordion'
@@ -1029,13 +1157,22 @@ $('#library').addEventListener('click', event => {
}
addElement(button.dataset.add);
});
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
$('#add-root-section').addEventListener('click', () => {
$('#cancel-insert').addEventListener('click', () => { setPendingInsert(null); if (selected) setView('element'); });
// The tabs at the top of the sidebar. "Seite" also drops the selection: its settings are what
// the page shows when nothing is chosen.
for (const tab of document.querySelectorAll('.sidebar-nav [role=tab]')) tab.addEventListener('click', () => {
if (busy || !history) return;
stopInlineEdit();
setPendingInsert(null);
selected = history.change(doc => insertBlock(doc, 'section', selected));
propertyPanel = 'content'; changed();
if (tab.dataset.view === 'page') { setView('page'); choose(null); return; }
setView(tab.dataset.view);
});
$('#go-parent').addEventListener('click', () => actions.parent(selected));
$('#close-element').addEventListener('click', () => choose(null));
// The plus next to the name: into a container, otherwise behind the element.
$('#add-here').addEventListener('click', () => {
const item = locate(history.document.blocks, selected);
if (!item || busy) return;
if (isContainer(item.block) && item.block.type !== 'accordion') beginContainerInsert(item.block.id);
else setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + 1 });
});
$('#presets').addEventListener('click', event => {
const name = event.target.closest('[data-preset]')?.dataset.preset;
@@ -1045,15 +1182,15 @@ $('#presets').addEventListener('click', event => {
$('#structure').addEventListener('click', event => {
const toggle = event.target.closest('[data-toggle]')?.dataset.toggle;
if (toggle) { collapsed.has(toggle) ? collapsed.delete(toggle) : collapsed.add(toggle); renderTree(); $(`[data-toggle="${toggle}"]`)?.focus({ preventScroll: true }); return; }
const edit = event.target.closest('[data-edit-block]')?.dataset.editBlock;
if (edit) { setView('element'); choose(edit); return; }
const id = event.target.closest('[data-block-id]')?.dataset.blockId; if (id) choose(id);
});
$('#tree-search').addEventListener('input', renderTree);
$('#toggle-library').addEventListener('click', () => {
const hidden = !$('#toolbox').hidden; $('#toolbox').hidden = hidden;
$('#toggle-library').setAttribute('aria-expanded', String(!hidden));
$('#toggle-library').setAttribute('aria-label', `Werkzeugkasten ${hidden ? 'öffnen' : 'einklappen'}`);
$('#toggle-library').textContent = hidden ? '+' : '−';
$('#structure').addEventListener('dblclick', event => {
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
if (id && !busy) { setView('element'); choose(id); }
});
$('#tree-search').addEventListener('input', renderTree);
$('#expand-all').addEventListener('click', () => { collapsed.clear(); renderTree(); });
$('#collapse-all').addEventListener('click', () => {
const visit = blocks => blocks.forEach(block => { if (block.children?.length) { collapsed.add(block.id); visit(block.children); } });
@@ -1104,6 +1241,53 @@ const actions = {
parent: id => choose(locate(history.document.blocks, id)?.parent?.id ?? null),
};
// ---------------------------------------------------------------------------------------------
// The context menu of the preview. Lives in the editor document, so it is drawn at full size
// above a scaled preview; the frame coordinates are converted once, here.
// ---------------------------------------------------------------------------------------------
function openContextMenu(id, frameX, frameY) {
const item = locate(history.document.blocks, id);
if (!item) return;
const menu = $('#context-menu');
const label = blockLabel(item.block);
const sibling = dropList(history.document, item.parent?.id ?? null, item.block);
const entries = [
{ text: `${label} bearbeiten`, run: () => { setView('element'); choose(id, false); } },
isContainer(item.block) && item.block.type !== 'accordion' ? { text: 'Element hinein', run: () => beginContainerInsert(id) } : null,
sibling ? { text: 'Element davor', run: () => setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index }) } : null,
sibling ? { text: 'Element dahinter', run: () => setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + 1 }) } : null,
{ text: 'Duplizieren', run: () => actions.duplicate(id) },
item.index > 0 ? { text: 'Nach oben', run: () => actions.move(id, -1) } : null,
item.index < item.siblings.length - 1 ? { text: 'Nach unten', run: () => actions.move(id, 1) } : null,
item.parent ? { text: `Übergeordnet: ${blockLabel(item.parent)}`, run: () => actions.parent(id) } : null,
{ text: 'Löschen', danger: true, run: () => actions.delete(id) },
].filter(Boolean);
menu.replaceChildren(...entries.map(entry => {
const button = document.createElement('button'); button.type = 'button'; button.setAttribute('role', 'menuitem');
button.textContent = entry.text; if (entry.danger) button.className = 'danger-text';
button.addEventListener('click', () => { closeContextMenu(); entry.run(); });
return button;
}));
const frame = $('#preview').getBoundingClientRect();
menu.hidden = false;
const x = frame.left + frameX * previewScale, y = frame.top + frameY * previewScale;
menu.style.left = `${Math.min(x, window.innerWidth - menu.offsetWidth - 8)}px`;
menu.style.top = `${Math.min(y, window.innerHeight - menu.offsetHeight - 8)}px`;
menu.querySelector('button')?.focus({ preventScroll: true });
}
function closeContextMenu() { $('#context-menu').hidden = true; }
document.addEventListener('mousedown', event => { if (event.button === 0 && !event.target.closest('#context-menu')) closeContextMenu(); });
window.addEventListener('blur', closeContextMenu);
window.addEventListener('resize', closeContextMenu);
$('.canvas-scroll').addEventListener('scroll', closeContextMenu, { passive: true });
$('#context-menu').addEventListener('keydown', event => {
const items = [...event.currentTarget.querySelectorAll('button')];
const index = items.indexOf(document.activeElement);
if (event.key === 'ArrowDown') { event.preventDefault(); items[(index + 1) % items.length]?.focus(); }
if (event.key === 'ArrowUp') { event.preventDefault(); items[(index - 1 + items.length) % items.length]?.focus(); }
if (event.key === 'Escape') { event.stopPropagation(); closeContextMenu(); $('.canvas-scroll').focus({ preventScroll: true }); }
});
$('#duplicate').addEventListener('click', () => actions.duplicate(selected));
$('#delete').addEventListener('click', () => actions.delete(selected));
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
@@ -1163,7 +1347,6 @@ $('#library').addEventListener('dragstart', event => {
});
$('#library').addEventListener('dragend', () => overlay.endDrag());
$('#select-page').addEventListener('click', () => choose(null));
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
$('#save').addEventListener('click', () => save());
@@ -1214,66 +1397,37 @@ function setViewport(name) {
}
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
// Panel sizes are workspace preferences, independent of document history and publication.
const panelSizes = { left: 280, right: 320 };
try {
const saved = JSON.parse(localStorage.getItem('kantsites.panel-widths'));
for (const side of ['left', 'right']) if (Number.isFinite(saved?.[side])) panelSizes[side] = saved[side];
} catch { /* Storage can be unavailable; resizing still works for this session. */ }
const panelMinimum = { left: 240, right: 280 };
function layoutPanels() {
let left = Math.max(240, Math.min(560, panelSizes.left));
let right = Math.max(280, Math.min(560, panelSizes.right));
const budget = Math.max(520, $('.editor-layout').clientWidth - 292);
const excess = Math.max(0, left + right - budget);
const spare = left + right - 520;
if (excess && spare) { left -= excess * (left - 240) / spare; right -= excess * (right - 280) / spare; }
for (const [side, size] of Object.entries({ left, right })) {
root.style.setProperty(`--panel-${side}`, `${size}px`);
const handle = $(`[data-resize="${side}"]`);
handle.setAttribute('aria-valuenow', String(Math.round(size)));
handle.setAttribute('aria-valuemax', String(Math.floor(Math.min(560, budget - (side === 'left' ? right : left)))));
handle.setAttribute('aria-valuetext', `${Math.round(size)} Pixel`);
/** Whether a key press belongs to a field rather than to the page. */
const inField = target => ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
// One handler for the editor window and for the preview document: a key pressed while the
// preview has the focus - after a click into it - must mean the same thing.
function onKey(event) {
if (!history || busy) return;
const key = event.key.toLowerCase();
if (event.ctrlKey || event.metaKey) {
if (key === 's') { event.preventDefault(); save(); }
// Keep native text-field undo intact; the toolbar always undoes the complete document.
if (inField(event.target)) return;
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
return;
}
if (event.key === 'Escape') {
if (!$('#context-menu').hidden) { closeContextMenu(); return; }
if (document.querySelector('dialog[open]') || inlineEdit) return;
if (pendingInsert) { setPendingInsert(null); if (selected) setView('element'); return; }
if (selected) { choose(null); $('.canvas-scroll').focus({ preventScroll: true }); }
return;
}
// Delete removes the selected element - but never while a field or the preview text is
// being typed in, and never something the sidebar buttons would refuse.
if ((event.key === 'Delete' || event.key === 'Backspace') && selected && !inField(event.target) && !inlineEdit && !$('#delete').disabled) {
event.preventDefault(); actions.delete(selected);
}
}
const persistPanels = () => { try { localStorage.setItem('kantsites.panel-widths', JSON.stringify(panelSizes)); } catch {} };
for (const handle of document.querySelectorAll('[data-resize]')) {
const side = handle.dataset.resize, direction = side === 'left' ? 1 : -1;
let drag = null;
const setSize = value => {
panelSizes[side] = Math.max(panelMinimum[side], Math.min(Number(handle.getAttribute('aria-valuemax')), value));
layoutPanels();
};
handle.addEventListener('pointerdown', event => {
if (event.button !== 0) return;
event.preventDefault(); handle.focus();
drag = { x: event.clientX, size: Number(handle.getAttribute('aria-valuenow')) };
handle.setPointerCapture(event.pointerId); root.classList.add('resizing-panels');
});
handle.addEventListener('pointermove', event => { if (drag) setSize(drag.size + direction * (event.clientX - drag.x)); });
const stop = () => { drag = null; root.classList.remove('resizing-panels'); persistPanels(); };
handle.addEventListener('pointerup', stop);
handle.addEventListener('pointercancel', stop);
handle.addEventListener('lostpointercapture', stop);
handle.addEventListener('dblclick', () => { panelSizes[side] = side === 'left' ? 280 : 320; layoutPanels(); persistPanels(); });
handle.addEventListener('keydown', event => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const current = Number(handle.getAttribute('aria-valuenow'));
setSize(event.key === 'Home' ? panelMinimum[side] : event.key === 'End' ? 560 : current + (event.key === 'ArrowRight' ? 1 : -1) * direction * (event.shiftKey ? 40 : 10));
persistPanels();
});
}
new ResizeObserver(layoutPanels).observe($('.editor-layout'));
layoutPanels();
window.addEventListener('keydown', event => {
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
const key = event.key.toLowerCase();
if (key === 's') { event.preventDefault(); save(); }
// Keep native text-field undo intact; the toolbar always undoes the complete document.
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)) return;
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
});
window.addEventListener('keydown', onKey);
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
buildLibrary();
// The workspace is a fixed frame; a scroll position restored from an earlier visit would push
// the header out of view.
window.scrollTo(0, 0);
load();
+14 -5
View File
@@ -1,11 +1,11 @@
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}}
.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}
/* Hover effects remain CSS; entrance effects are observed by motion.js. */
@media (prefers-reduced-motion: no-preference) {
/* Hover effects remain CSS; entrance effects are observed by motion.js. They play for everyone
unless the website chooses to respect the browser preference - see the override at the end. */
.fx-hover-sink { transition: transform .2s ease }
.fx-hover-sink:hover { transform: translateY(3px) }
.fx-hover-glow { transition: box-shadow .2s ease }
@@ -14,7 +14,6 @@
.fx-hover-tilt:hover { transform: rotate(-1.2deg) scale(1.01) }
.fx-hover-grow-border { transition: box-shadow .2s ease }
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
}
/* Beitragskarten und KnÃpfe, ergänzt am 13.09.2026.
@@ -22,7 +21,6 @@
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
@media (prefers-reduced-motion: no-preference) {
.post-card { transition: transform .28s ease, box-shadow .28s ease, border-color .28s ease }
/* Eine Zeile in der kompakten Liste hebt sich nicht - sie ist keine Karte. */
.post-list-kompakt .post-card:hover, .post-list-kompakt .post-card:focus-within { transform: none; box-shadow: none; border-color: var(--accent) }
@@ -44,4 +42,15 @@
/* Der Knopf hebt sich beim Zeigen um zwei Pixel - dieselbe Geste wie auf der Karte. */
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
.button:hover { transform: translateY(-2px) }
/* Die Website kann die Browservorgabe „Bewegung reduzieren" beachten (Einstellungen → Bewegung).
Dann trägt <html> die Klasse motion-respect, und die Hover-Fahrten fallen weg - Farben und
Schatten bleiben, nur nichts bewegt sich mehr. Ohne die Klasse spielt alles, wie bei Flatsome. */
@media (prefers-reduced-motion: reduce) {
html.motion-respect :is(.fx-hover-lift, .fx-hover-zoom img, .fx-hover-sink, .fx-hover-tilt, .post-card, .post-image img, .post-nav-link, .button) { transition: none }
html.motion-respect :is(.fx-hover-lift, .fx-hover-sink, .fx-hover-tilt, .post-card, .post-nav-link, .button):hover,
html.motion-respect :is(.fx-hover-lift, .post-card):focus-within,
html.motion-respect .post-nav-link:focus-visible,
html.motion-respect .fx-hover-zoom:hover img, html.motion-respect .fx-hover-zoom:focus-within img,
html.motion-respect .post-card:hover .post-image img, html.motion-respect .post-card:focus-within .post-image img { transform: none }
}
+65 -3
View File
@@ -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);
+77 -16
View File
@@ -1,35 +1,96 @@
/** Visible content is the baseline. Enhance only when it enters the viewport. */
/**
* Entrance effects.
*
* Visible content is the baseline: without this script every block simply stands on the page.
* With it, a block that carries an entrance class waits out of sight until it scrolls into
* view, then comes in - the way the Flatsome animations behave.
*
* Whether the browser preference "reduce motion" counts is the website's decision, made under
* Einstellungen and delivered as a class on <html>. Without `motion-respect` the effects play
* for everyone; many systems report the preference without anyone having chosen it, and a site
* whose animations never show is not what the person building it asked for.
*/
export function initMotion(doc = document) {
const view = doc.defaultView;
const html = doc.documentElement;
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
const reduced = () => html.classList.contains('motion-respect') && preference.matches;
const animations = new Set();
const seen = new WeakSet();
// Blocks hidden while they wait. Kept so they can be shown again the moment the effects are
// switched off - a block must never stay invisible because of a preference.
const hidden = new Set();
const start = {
fade: { opacity: .2 }, rise: { transform: 'translateY(28px)' }, sink: { transform: 'translateY(-28px)' },
left: { transform: 'translateX(-36px)' }, right: { transform: 'translateX(36px)' },
zoom: { transform: 'scale(.92)' }, flip: { transform: 'perspective(800px) rotateX(-16deg)' }, blur: { filter: 'blur(6px)' },
fade: { opacity: 0 },
rise: { opacity: 0, transform: 'translateY(40px)' },
sink: { opacity: 0, transform: 'translateY(-40px)' },
left: { opacity: 0, transform: 'translateX(-56px)' },
right: { opacity: 0, transform: 'translateX(56px)' },
zoom: { opacity: 0, transform: 'scale(.88)' },
flip: { opacity: 0, transform: 'perspective(800px) rotateX(-18deg)' },
blur: { opacity: 0, filter: 'blur(8px)' },
};
const nodes = [...doc.querySelectorAll('[class*="fx-entrance-"]')].filter(node => Object.keys(start).some(name => node.classList.contains(`fx-entrance-${name}`)));
const nameOf = node => Object.keys(start).find(name => node.classList.contains(`fx-entrance-${name}`));
const nodes = [...doc.querySelectorAll('[class*="fx-entrance-"]')].filter(nameOf);
const hide = node => { if (!seen.has(node) && !hidden.has(node)) { hidden.add(node); node.style.opacity = '0'; } };
const unhide = node => { if (hidden.delete(node)) node.style.opacity = ''; };
const show = node => {
if (seen.has(node) || preference.matches) return;
if (seen.has(node) || reduced()) return;
seen.add(node);
const name = Object.keys(start).find(name => node.classList.contains(`fx-entrance-${name}`));
unhide(node);
const name = nameOf(node);
const delay = node.classList.contains('fx-delay-long') ? 600 : node.classList.contains('fx-delay-medium') ? 300 : node.classList.contains('fx-delay-short') ? 150 : 0;
// Fade itself is optional; direction, zoom and flip keep text at full contrast.
// The block's own transform and filter, if any, are the end state - the entrance is
// composed in front of them rather than replacing them.
const style = view.getComputedStyle(node);
const finish = { opacity: style.opacity, transform: style.transform, filter: style.filter };
const first = { ...start[name] };
if (first.transform && finish.transform !== 'none') first.transform += ' ' + finish.transform;
const animation = node.animate([first, finish], { duration: 650, delay, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'none' });
animations.add(animation); animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
if (first.filter && finish.filter !== 'none') first.filter += ' ' + finish.filter;
// `backwards` holds the start state through the delay, so a delayed block does not flash
// up and vanish before its turn.
const animation = node.animate([first, finish], { duration: 750, delay, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'backwards' });
animations.add(animation);
animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
};
/** A block the reader has already scrolled past: shown at once, no entrance for nobody. */
const passed = node => { seen.add(node); unhide(node); observer.unobserve(node); };
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); };
for (const entry of entries) {
if (entry.isIntersecting) { show(entry.target); observer.unobserve(entry.target); }
else if (entry.boundingClientRect.bottom < 0) passed(entry.target);
else hide(entry.target);
}
}, { threshold: 0, rootMargin: '0px 0px -40px 0px' });
// A jump - an anchor link, a fast scroll, a restored position - can carry the reader past a
// block without it ever intersecting. Nothing that is above the viewport may stay hidden.
let sweeping = false;
const sweep = () => {
sweeping = false;
for (const node of [...hidden]) if (node.getBoundingClientRect().bottom < 0) passed(node);
};
const onScroll = () => { if (!sweeping) { sweeping = true; view.requestAnimationFrame(sweep); } };
const syncPreference = () => {
if (reduced()) {
observer.disconnect();
animations.forEach(animation => animation.cancel());
[...hidden].forEach(unhide);
} 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);
view.addEventListener('scroll', onScroll, { passive: true });
syncPreference();
return () => {
observer.disconnect();
animations.forEach(animation => animation.cancel());
[...hidden].forEach(unhide);
preference.removeEventListener('change', syncPreference);
view.removeEventListener('scroll', onScroll);
};
}
if (!document.querySelector('#editor')) initMotion();
+19
View File
@@ -0,0 +1,19 @@
/**
* Marks the page as scrolled.
*
* A header can carry a second set of colours for the scrolled page - transparent over the hero,
* solid once the reader has moved on, the way the Flatsome transparent header works. The
* stylesheet holds both states; this only says which one applies. Without the script the header
* keeps its normal colours, which is a readable page, not a broken one.
*/
export function initScrolled(doc = document) {
const view = doc.defaultView;
let queued = false;
const sync = () => { queued = false; doc.documentElement.classList.toggle('is-scrolled', view.scrollY > 40); };
const onScroll = () => { if (!queued) { queued = true; view.requestAnimationFrame(sync); } };
view.addEventListener('scroll', onScroll, { passive: true });
sync();
return () => { view.removeEventListener('scroll', onScroll); doc.documentElement.classList.remove('is-scrolled'); };
}
if (!document.querySelector('#editor')) initScrolled();
+37 -8
View File
@@ -6,7 +6,11 @@ h1,h2,h3,h4,h5,h6{hyphens:manual}
p{hyphens:auto}
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}
/* Über dem Inhalt: der Kopf nimmt keinen Platz ein, die erste Sektion beginnt unter ihm. Farbe
und Schatten kommen aus der Gestaltung des Kopfbereichs, auch die für den gescrollten Zustand. */
.site-header.is-overlay{position:absolute;top:0;left:0;right:0;z-index:40;border-bottom:0;background:transparent;box-shadow:none}
.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
@@ -41,7 +45,11 @@ p{hyphens:auto}
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
.block-icon{flex-shrink:0;display:block}
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
letzte Karte um genau diesen Betrag niedriger als die erste, weil ein Rand im Rasterfeld liegt. */
.block-row > .block-iconcard + .block-iconcard,.block-row > .block-quote + .block-quote,.block-row > .block-cta + .block-cta,.block-row > .block-iconcard + .block-quote,.block-row > .block-quote + .block-iconcard{margin-top:0}
.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.block-iconcard h3 a{text-decoration:none}
.block-iconcard h3 a:hover{text-decoration:underline}
.block-iconcard p{margin:0;line-height:1.65}
@@ -56,7 +64,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 +95,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}
@@ -194,7 +203,7 @@ body.consent-open{padding-bottom:260px}
.slider-dots{position:relative}
@supports selector(::scroll-marker){.slider-dots:not(.slider-controlled){scroll-marker-group:after;scroll-behavior:smooth;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots:not(.slider-controlled)::-webkit-scrollbar{display:none}.slider-dots:not(.slider-controlled)>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}}
.slider-controlled{scrollbar-width:none;scrollbar-gutter:auto}.slider-controlled::-webkit-scrollbar{display:none}
.slider-controls{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px;min-height:48px;margin-top:8px;padding:4px;font:14px/1.4 system-ui,sans-serif}.slider-controls button{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:40px;padding:8px 12px;border:1px solid #b2c2b8;border-radius:7px;background:#fff;color:#193c2d;cursor:pointer;font:inherit}.slider-controls button:focus-visible{outline:3px solid #b55b12;outline-offset:3px}.slider-controls button:hover{background:#e4efe7}.slider-controls button:disabled{opacity:.6;cursor:default}.slider-position{font-variant-numeric:tabular-nums;min-width:44px;text-align:center}.slider-control-dots{display:flex;flex-wrap:wrap;gap:3px}.slider-control-dots button{min-width:30px;padding:5px;border:0;background:transparent;color:inherit;opacity:.5}.slider-control-dots button[aria-pressed=true]{opacity:1;outline:1px solid currentColor;outline-offset:-5px}.slider-controls[hidden]{display:none}
.slider-controls{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px;min-height:48px;margin-top:8px;padding:4px;font:14px/1.4 system-ui,sans-serif}.slider-controls button{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:40px;padding:8px 12px;border:1px solid #b2c2b8;border-radius:7px;background:#fff;color:#193c2d;cursor:pointer;font:inherit}.slider-controls button:focus-visible{outline:3px solid #b55b12;outline-offset:3px}.slider-controls button:hover{background:#e4efe7}.slider-controls button:disabled{opacity:.6;cursor:default}.slider-position{font-variant-numeric:tabular-nums;min-width:44px;text-align:center}.slider-control-dots{display:flex;flex-wrap:wrap;gap:3px}.slider-control-dots button{min-width:32px;padding:0;border:0;background:transparent;color:inherit;font-size:0}.slider-control-dots button::before{content:'';width:10px;height:10px;border-radius:50%;border:2px solid currentColor;background:transparent;transition:background-color .2s}.slider-control-dots button[aria-pressed=true]::before{background:currentColor}.slider-control-dots button:hover::before{background:color-mix(in srgb,currentColor 50%,transparent)}.slider-controls[hidden]{display:none}
@media(max-width:650px){.slider-controls button{min-height:44px;min-width:44px}}
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
@@ -332,6 +341,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
@@ -345,10 +372,10 @@ body.consent-open{padding-bottom:260px}
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
body.consent-open .scroll-top{display:none}
@supports (animation-timeline: scroll()){
@media (prefers-reduced-motion: no-preference){
.scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)}
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
}
.scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)}
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
/* Beachtet die Website die Browservorgabe (Einstellungen → Bewegung), steht er von Anfang an da. */
@media (prefers-reduced-motion: reduce){html.motion-respect .scroll-top{animation:none}}
}
/* Der Bildbetrachter. Die Fläche daneben schließt, das Bild bleibt in seinen Grenzen, und die
Bedienknöpfe sind groß genug für einen Daumen. Ohne Skript wird nichts davon gebraucht - dann
@@ -373,3 +400,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}
+36 -11
View File
@@ -8,9 +8,12 @@ export function initSliders(doc = document) {
slider.dataset.enhanced = 'true';
const events = new view.AbortController(), signal = events.signal;
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
// The browser preference counts only when the website says so (Einstellungen → Bewegung).
const reduced = () => doc.documentElement.classList.contains('motion-respect') && preference.matches;
const seconds = Number(slider.dataset.autoplay || 0);
let playing = seconds >= 3 && !preference.matches, hover = false, visible = false, timer;
let playing = seconds >= 3 && !reduced(), 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 +22,38 @@ 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;
};
let settle = null;
const go = index => {
slider.scrollTo({ left: Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index]))), behavior: preference.matches ? 'instant' : 'smooth' });
if (!available().includes(index)) return;
const target = Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index])));
if (reduced()) { slider.scrollTo({ left: target, behavior: 'instant' }); return; }
// Safari cancels a smooth scroll on a snapping container and snaps back to the slide it
// came from - the slider stutters instead of moving on. Snapping is switched off for the
// ride and put back once the movement has settled, on the exact start of a slide, so
// restoring it moves nothing.
clearInterval(settle);
const snap = slider.style.scrollSnapType;
slider.style.scrollSnapType = 'none';
slider.scrollTo({ left: target, behavior: 'smooth' });
// Restored on arrival, or after two seconds if the ride was interrupted - a background tab
// pauses smooth scrolling, and snapping must not come back halfway.
let ticks = 0;
settle = setInterval(() => {
if (Math.abs(slider.scrollLeft - target) < 1 || ++ticks > 25) { clearInterval(settle); settle = null; slider.style.scrollSnapType = snap; }
}, 80);
};
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,23 +68,25 @@ 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');
play.disabled = preference.matches; play.title = preference.matches ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : '';
play.disabled = reduced(); play.title = reduced() ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : '';
}
};
const schedule = () => {
clearTimeout(timer);
if (playing && !preference.matches) timer = setTimeout(() => { if (playing && !hover && visible && !doc.hidden) step(1); schedule(); }, seconds * 1000);
if (playing && !reduced()) timer = setTimeout(() => { if (playing && !hover && visible && !doc.hidden) step(1); schedule(); }, seconds * 1000);
};
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 });
@@ -71,9 +96,9 @@ export function initSliders(doc = document) {
}, { signal });
const observer = new view.IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0 }); observer.observe(slider);
const resize = new view.ResizeObserver(update); resize.observe(slider);
preference.addEventListener('change', () => { if (preference.matches) pause(); update(); }, { signal });
preference.addEventListener('change', () => { if (reduced()) pause(); update(); }, { signal });
schedule();
cleanups.push(() => { clearTimeout(timer); events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
cleanups.push(() => { clearTimeout(timer); clearInterval(settle); slider.style.scrollSnapType = ''; events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
});
return () => cleanups.forEach(cleanup => cleanup());
}
+11 -3
View File
@@ -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');
};
+39
View File
@@ -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();
}
+97
View File
@@ -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));
}
+5
View File
@@ -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`);
+139
View File
@@ -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(); }
+61 -48
View File
@@ -55,6 +55,10 @@ try {
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
checks.push({ name, violations: 0 });
};
const tab = name => page.getByRole('tab', { name, exact: true }).click();
const treeSelect = async id => { await tab('Struktur'); await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`#structure [data-edit-block="${id}"]`).click(); };
const treeToggle = async id => { await tab('Struktur'); await page.locator(`#structure [data-toggle="${id}"]`).click(); };
const addFromLibrary = async selector => { await tab('Elemente'); await page.locator(`#library ${selector}`).click(); };
await page.goto(origin + '/admin');
assert.equal(new URL(page.url()).pathname, '/admin/login');
await axe('login');
@@ -104,31 +108,22 @@ try {
const frame = page.frameLocator('#preview');
await frame.locator('h1').waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
// Flexible panels, contextual fields and a root insertion from a deeply selected element.
const leftHandle = page.locator('[data-resize="left"]'), rightHandle = page.locator('[data-resize="right"]');
await leftHandle.focus(); await page.keyboard.press('ArrowRight');
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
const rightBox = await rightHandle.boundingBox();
await page.mouse.move(rightBox.x + 3, rightBox.y + 100); await page.mouse.down();
await page.mouse.move(rightBox.x - 57, rightBox.y + 100, { steps: 6 }); await page.mouse.up();
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
// One sidebar, contextual fields and a root insertion from a deeply selected element.
await page.reload();
await frame.locator('h1').waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
for (const width of [1920, 1100, 850, 390]) {
await page.setViewportSize({ width, height: 1100 });
await page.waitForTimeout(100);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `workspace fits ${width}`);
for (const selector of ['.left-panel', '.right-panel', '.library-buttons']) {
for (const selector of ['.sidebar', '.sidebar-body', '.library-buttons']) {
assert.equal(await page.locator(selector).first().evaluate(el => el.scrollWidth <= el.clientWidth + 1), true, `${selector} fits ${width}`);
}
}
await page.setViewportSize({ width: 1600, height: 1100 });
await frame.locator('h1').click();
const sectionCount = await frame.locator('main > .block-section').count();
await page.locator('#add-root-section').click();
await addFromLibrary('[data-add="section"]');
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
await page.locator('#undo').click();
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count, sectionCount);
@@ -136,7 +131,7 @@ try {
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
await page.locator('#undo').click();
await frame.locator('h1').click();
await page.locator('#library [data-add="image"]').click();
await addFromLibrary('[data-add="image"]');
await page.locator('[data-panel="design"]').click();
assert.equal(await page.locator('#style-fontSize').count(), 0);
assert.equal(await page.locator('#style-objectFit').isVisible(), true);
@@ -169,17 +164,15 @@ try {
await page.locator('#undo').click();
await page.locator('#undo').click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.locator('.right-panel').evaluate(el => el.scrollTop = 0);
await page.locator('.left-panel > .panel-section').first().evaluate(el => el.scrollTop = 0);
await page.locator('.sidebar-body').evaluate(el => el.scrollTop = 0);
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
await leftHandle.dblclick(); await rightHandle.dblclick();
await page.locator('[data-panel="content"]').click();
checks.push({ name: 'resizable persistent panels, root sections, contextual image/text properties and full text width', passed: true });
checks.push({ name: 'one sidebar, root sections, contextual image/text properties and full text width', passed: true });
// A layout starts empty: it must be visible, reachable and editable before it has any text.
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await frame.locator('h1').click();
await page.locator('#add-root-section').click();
await addFromLibrary('[data-add="section"]');
await previewReady();
const emptySection = frame.locator('main > .editor-empty-container');
await emptySection.waitFor();
@@ -197,10 +190,11 @@ try {
assert.equal(await page.locator('#library-search').evaluate(el => el === document.activeElement), true);
assert.match(await page.locator('#insert-hint').innerText(), /Spalte 2 von 2/);
await page.locator('#library-search').fill('Text');
await page.locator('#library [data-add="text"]').click();
await addFromLibrary('[data-add="text"]');
await frame.locator(`#${secondColumnId} .block-text`).waitFor();
await previewReady();
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
await tab('Elemente');
await page.locator('#library-search').fill('');
const dragSource = page.locator('#library [data-add="button"]');
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
@@ -249,6 +243,7 @@ try {
const destination = frame.locator(`#${secondColumnId}`);
await destination.scrollIntoViewIfNeeded();
const box = await destination.boundingBox();
await tab('Struktur');
await page.locator(`#structure [data-block-id="${firstColumnId}"]`).dragTo(destination, {
targetPosition: device === 'Desktop' ? { x: box.width - 8, y: 8 } : { x: 8, y: box.height - 8 },
});
@@ -268,7 +263,7 @@ try {
assert.ok(publishedEmpty.includes(newSectionId));
assert.equal(/editor-empty|data-editor-target|preview-containers|preview\.css|Inhalt hinzufügen/.test(publishedEmpty), false, 'public output has no editor aids');
// Restore the fixture through the same editor actions; subsequent tests retain their baseline.
await page.locator(`#structure [data-block-id="${newSectionId}"]`).click();
await treeSelect(newSectionId);
await page.locator('#delete').click();
await page.locator('#save').click();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
@@ -306,7 +301,7 @@ try {
const headingId = await inPreview('h1');
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
await page.locator('#insert-hint').waitFor({ state: 'visible' });
await page.locator('#library button[data-add="text"]').click();
await addFromLibrary('button[data-add="text"]');
assert.equal(await page.locator('#properties-title').innerText(), 'Text');
assert.equal(await page.locator('#insert-hint').isVisible(), false);
await page.waitForFunction(id => {
@@ -318,6 +313,7 @@ try {
// Ziehen aus dem Werkzeugkasten auf die Seite.
await frame.locator('h1').waitFor();
const beforeButtons = await page.locator('#structure [data-block-id]').count();
await tab('Elemente');
await page.locator('#library button[data-add="button"]').dragTo(frame.locator('h1'), { targetPosition: { x: 12, y: 4 } });
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
assert.equal(await page.locator('#properties-title').innerText(), 'Button');
@@ -351,15 +347,16 @@ try {
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
// einem anderen Dokument.
await tab('Struktur');
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
assert.equal(await page.locator('#properties-title').innerText(), 'Sektion');
assert.equal(await page.locator('#properties-title').textContent(), 'Sektion');
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
// Und in der Zeile wird die Spalte moeglich.
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
await page.locator('#library button[data-row="3"]').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Zeile');
await addFromLibrary('button[data-row="3"]');
assert.equal(await page.locator('#properties-title').textContent(), 'Zeile');
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
assert.match((await toolboxState('column')).title, /Wird in Zeile eingefügt/);
// Die Zeile bringt ihre drei Spalten mit. Die Testseite hat schon welche, also zaehlt die
@@ -367,6 +364,7 @@ try {
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
// Jeder Eintrag traegt eine Zeichnung statt eines Schriftzeichens.
await tab('Elemente');
const mitBild = await page.locator('#library button .library-preview svg').count();
const knoepfe = await page.locator('#library button').count();
assert.equal(mitBild, knoepfe, `alle ${knoepfe} Eintraege haben ein Vorschaubild`);
@@ -431,6 +429,7 @@ try {
checks.push({ name: 'preview never shows unstyled content', passed: true });
console.log('Preview edits and history passed.');
await tab('Struktur');
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
assert.equal(await page.locator('#structure .tree-item').count(), 3);
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('Meine bearbeitete');
@@ -440,6 +439,8 @@ try {
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('');
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click();
// Enter in the tree only selects; editing is the button next to the selected row.
await page.locator('#structure [data-edit-block="headline"]').click();
await page.getByRole('button', { name: 'Design', exact: true }).click();
const frameValue = async (selector, property, expected) => {
@@ -495,7 +496,7 @@ try {
assert.match(await contrast.textContent(), /Kontrast \d/, 'The panel reports a contrast ratio.');
// Per side spacing on the section, and a hover rule that also covers keyboard focus.
await page.locator('#structure [data-block-id="hero"]').click();
await treeSelect('hero');
await page.getByRole('button', { name: 'Design', exact: true }).click();
await setLength('style-padding-top', 96);
await frameValue('#hero', 'paddingTop', '96px');
@@ -539,7 +540,7 @@ try {
await frameValue('#hero', 'paddingTop', '640px');
// Das Ziehfeld: seitwaerts ziehen aendert den Wert, und der ganze Zug ist ein Undo-Schritt.
await page.locator('#structure [data-block-id="headline"]').click();
await treeSelect('headline');
await openGroup('Typografie');
const scrub = page.locator('#style-fontSize');
await scrub.scrollIntoViewIfNeeded();
@@ -558,7 +559,7 @@ try {
// Aufraeumen: dieser Abschnitt hat den Innenabstand des Hero auf 40rem an allen Seiten
// gebracht. Das drueckt die Spaltenbreite auf null, und alles Folgende haette unsichtbare
// Inhalte gepruef. Zurueck auf den Zustand von vorher: 96 px oben, sonst nichts.
await page.locator('#structure [data-block-id="hero"]').click();
await treeSelect('hero');
await page.getByRole('button', { name: 'Design', exact: true }).click();
await page.locator('#style-padding-unit').selectOption('px');
for (const seite of ['right', 'bottom', 'left']) await setLength(`style-padding-${seite}`, '');
@@ -587,6 +588,7 @@ try {
});
checks.push({ name: 'free styles: units, inheritance, single reset, palette colour, per-side spacing, hover rule', passed: true });
await tab('Elemente');
await page.locator('[data-preset="columns"]').click();
const presetSection = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
const presetRow = `#${presetSection} .block-row`;
@@ -607,12 +609,12 @@ try {
await frame.locator(presetRow).waitFor({ state: 'detached' });
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
await columnCount(2);
await page.locator('#structure [data-block-id="headline"]').click();
await treeSelect('headline');
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
checks.push({ name: 'keyboard tree search and collapse, responsive inheritance/reset/undo, true viewport widths, spacing and responsive section preset', passed: true });
console.log('Responsive design, tree tools and presets passed.');
await page.locator('#library [data-add="gallery"]').click();
await addFromLibrary('[data-add="gallery"]');
const galleryId = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
await page.getByRole('button', { name: 'Galeriebilder auswählen', exact: true }).click();
await page.locator('#media-status').filter({ hasText: '0 Bilder' }).waitFor();
@@ -639,12 +641,12 @@ try {
await frame.locator(`#${galleryId} img`).first().scrollIntoViewIfNeeded();
await page.waitForFunction(id => document.querySelector('#preview').contentDocument?.querySelector(`#${id} img`)?.naturalWidth > 0, galleryId);
const imageId = await frame.locator(`#${galleryId} figure`).first().getAttribute('id');
await page.locator(`#structure [data-block-id="${imageId}"]`).click();
await treeSelect(imageId);
await page.getByLabel('Bildunterschrift', { exact: true }).fill('Ein Ausblick zum Durchatmen.');
await frame.getByText('Ein Ausblick zum Durchatmen.', { exact: true }).waitFor();
await page.getByRole('button', { name: 'Nach unten verschieben', exact: true }).click();
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator(`#structure [data-block-id="${galleryId}"]`).click();
await treeSelect(galleryId);
await page.getByRole('button', { name: 'Design', exact: true }).click();
await openGroup('Layout');
await page.locator('#style-gridColumns').fill('2');
@@ -661,7 +663,7 @@ try {
await openGroup('Bewegung und Effekte');
await page.getByLabel('Schatten', { exact: true }).selectOption('soft');
await page.getByLabel('Hover-Effekt', { exact: true }).selectOption('lift');
for (const [effect, transform] of [['left', 'translateX(-36px)'], ['right', 'translateX(36px)']]) {
for (const [effect, transform] of [['left', 'translateX(-56px)'], ['right', 'translateX(56px)']]) {
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption(effect);
await frame.locator(`#${galleryId}.fx-entrance-${effect}`).waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
@@ -672,19 +674,31 @@ try {
element.animate = (frames, options) => { window.observedEntrance.push(frames); return animate(frames, options); };
element.scrollIntoView();
}, galleryId);
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
await page.waitForFunction(() => window.observedEntrance.length > 0);
assert.equal(await page.evaluate(() => window.observedEntrance[0][0].transform), transform);
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
}
// Default setting: the preview plays even when the browser asks for less motion (Flatsome mode).
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(() => { window.observedEntrance = []; });
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
await page.waitForTimeout(800);
assert.equal(await page.evaluate(() => window.observedEntrance.length), 0, 'reduced motion suppresses entrance playback');
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
await page.waitForFunction(() => window.observedEntrance.length > 0);
assert.equal(await page.locator('#motion-status').isVisible(), false, 'no motion note while the site ignores the preference');
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
// Setting "Browservorgabe beachten", as the preview would carry it: nothing plays, the note shows.
await page.evaluate(() => { document.querySelector('#preview').contentDocument.documentElement.classList.add('motion-respect'); window.observedEntrance = []; });
await page.emulateMedia({ reducedMotion: 'no-preference' });
checks.push({ name: 'distinct left/right entrance playback and reduced motion in the sandbox', passed: true });
await page.waitForTimeout(200);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.locator('#motion-status').waitFor({ state: 'visible' });
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
await page.waitForTimeout(800);
assert.equal(await page.evaluate(() => window.observedEntrance.length), 0, 'reduced motion suppresses entrance playback when respected');
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.locator('#motion-status').waitFor({ state: 'hidden' });
checks.push({ name: 'distinct left/right entrance playback, Flatsome-mode playback and respected reduced motion in the sandbox', passed: true });
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption('rise');
await frame.locator(`#${galleryId}.fx-entrance-rise`).waitFor();
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
@@ -698,7 +712,7 @@ try {
await frameValue(`#${galleryId}`, 'animationName', 'none');
await frameValue(`#${galleryId}`, 'transform', 'none');
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.locator('#open-media-library').click();
await (await tab('Elemente'), page.locator('#open-media-library').click());
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
await page.locator(`[data-media-id="${mediaItems[0].id}"]`).click();
await page.getByRole('button', { name: 'Archivieren', exact: true }).click();
@@ -709,25 +723,23 @@ try {
await page.getByRole('button', { name: 'Wiederherstellen', exact: true }).click();
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
await page.locator('#structure [data-block-id="headline"]').click();
await treeSelect('headline');
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
checks.push({ name: 'image upload, alt metadata, multi-select gallery, caption, sorting undo, archive/restore, responsive variants and reduced-motion effects', passed: true });
console.log('Media library, gallery and effects passed.');
await page.locator('#library [data-add="text"]').click();
await addFromLibrary('[data-add="text"]');
await page.getByLabel('Text', { exact: true }).fill('Dieser Absatz wurde im Editor eingefügt.');
await page.getByRole('button', { name: 'Duplizieren', exact: true }).click();
await page.getByLabel('Text', { exact: true }).fill('Das ist eine unabhängige Kopie.');
await page.getByRole('button', { name: 'Nach oben verschieben', exact: true }).click();
await page.getByRole('button', { name: 'Löschen', exact: true }).click();
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator('#toggle-library').click();
await page.locator(`#structure [data-toggle="${presetSection}"]`).click();
await page.locator(`#structure [data-toggle="${galleryId}"]`).click();
await treeToggle(presetSection);
await treeToggle(galleryId);
await page.getByRole('button', { name: 'Text: Dieser Absatz wurde im Editor eingefügt.', exact: true }).dragTo(page.locator('#structure [data-block-id="principles"]'));
await page.waitForFunction(() => document.querySelector('#structure [data-block-id="principles"]')?.parentElement.textContent.includes('Dieser Absatz wurde im Editor eingefügt.'));
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator('#toggle-library').click();
await page.route('**/admin/api/pages/test-home/draft', route => route.abort(), { times: 1 });
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
await page.locator('#message').filter({ hasText: 'Verbindung ist unterbrochen' }).waitFor();
@@ -881,8 +893,9 @@ try {
// Back into the editor. The draft differs from the published state by now, so wait for the
// structure rather than for a particular save status.
await page.goto(origin + '/admin/pages/test-home');
await tab('Struktur');
await page.locator('#structure [data-block-id="headline"]').waitFor();
await page.locator('#structure [data-block-id="headline"]').click();
await treeSelect('headline');
await page.getByRole('button', { name: 'Design', exact: true }).click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await axe('desktop editor');
@@ -894,7 +907,7 @@ try {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await axe('mobile editor');
await page.screenshot({ path: 'reports/editor/editor-phone.png', fullPage: true });
await page.locator('#open-media-library').click();
await (await tab('Elemente'), page.locator('#open-media-library').click());
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
await axe('mobile media library');
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
+188
View File
@@ -0,0 +1,188 @@
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('#abschluss-cta');
assert.equal(await page.evaluate(() => motionCalls.some(call => call.id === 'abschluss-cta')), false, 'Below-fold section must wait for scrolling');
assert.equal(await target.evaluate(node => node.style.opacity), '0', 'Below-fold block waits out of sight, as in Flatsome');
await target.scrollIntoViewIfNeeded();
await page.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'));
const calls = await page.evaluate(() => motionCalls);
assert.equal(calls.find(call => call.id === 'abschluss-cta').frames[0].transform, 'translateY(40px)');
assert.equal(calls.find(call => call.id === 'abschluss-cta').frames[0].opacity, 0, 'Entrance fades in from invisible, as in Flatsome');
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 === 'abschluss-cta').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('abschluss-cta');
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 === 'abschluss-cta'));
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(-56px)' : 'translateX(56px)');
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());
}
// Default setting: the website plays regardless of the browser preference (Flatsome mode).
// Umut's Chrome reports "reduce" without anyone having chosen it; the site must still move.
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.reload(); await target.scrollIntoViewIfNeeded();
await page.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'), null, { timeout: 3000 });
await page.waitForTimeout(900);
assert.equal(await target.evaluate(node => getComputedStyle(node).opacity), '1', 'Block ends fully visible');
await hoverTarget.scrollIntoViewIfNeeded(); await hoverTarget.hover(); await page.waitForTimeout(350);
assert.equal(await hoverTarget.evaluate(node => getComputedStyle(node).transform), 'matrix(1, 0, 0, 1, 0, -4)', 'Default setting keeps the hover lift under a reduce preference');
await page.emulateMedia({ reducedMotion: 'no-preference' });
await context.close();
// Setting "Browservorgabe beachten": <html class="motion-respect">. Simulated by adding the
// class before any script runs, exactly where the template would print it.
const respect = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'reduce' });
await respect.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;
};
new MutationObserver((_, observer) => {
if (!document.documentElement) return;
document.documentElement.classList.add('motion-respect'); observer.disconnect();
}).observe(document, { childList: true });
});
const respectPage = await respect.newPage(); respectPage.on('pageerror', error => errors.push(error.message));
const respectTarget = respectPage.locator('#abschluss-cta'), respectHover = respectPage.locator('#leistung-app');
await respectPage.goto(origin); await respectTarget.scrollIntoViewIfNeeded(); await respectPage.waitForTimeout(800);
assert.equal(await respectPage.evaluate(() => motionCalls.length), 0, 'Reduced motion suppresses animations when respected');
assert.equal(await respectTarget.evaluate(node => getComputedStyle(node).opacity), '1');
await respectHover.scrollIntoViewIfNeeded(); await respectHover.hover(); await respectPage.waitForTimeout(350);
assert.equal(await respectHover.evaluate(node => getComputedStyle(node).transform), 'none', 'Reduced motion suppresses hover movement when respected');
await respectTarget.scrollIntoViewIfNeeded();
await respectPage.emulateMedia({ reducedMotion: 'no-preference' });
await respectPage.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'), null, { timeout: 3000 });
await respectPage.waitForTimeout(900);
await respectPage.emulateMedia({ reducedMotion: 'reduce' });
await respectPage.emulateMedia({ reducedMotion: 'no-preference' });
await respectPage.waitForTimeout(100);
assert.equal(await respectPage.evaluate(() => motionCalls.filter(call => call.id === 'abschluss-cta').length), 1, 'Preference changes do not replay an already seen element');
await respectHover.scrollIntoViewIfNeeded(); await respectHover.hover(); await respectPage.waitForTimeout(350);
assert.equal(await respectHover.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 respectPage.reload(); await respectPage.waitForTimeout(800);
await respectPage.emulateMedia({ reducedMotion: 'reduce' });
await respectTarget.scrollIntoViewIfNeeded(); await respectPage.waitForTimeout(100);
assert.equal(await respectPage.evaluate(() => motionCalls.filter(call => call.id === 'abschluss-cta').length), 0);
assert.equal(await respectTarget.evaluate(node => getComputedStyle(node).opacity), '1', 'A waiting block is shown again when motion is reduced');
await respectPage.emulateMedia({ reducedMotion: 'no-preference' });
await respectPage.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'), null, { timeout: 3000 });
results.push({ width, inventory, calls, checks: ['scroll trigger', 'once only', 'hidden until visible', 'left/right computed movement', 'saved hover lift', 'default plays under reduce preference', 'motion-respect honours reduce', 'resume without reloading after initial and subsequent reduced motion', 'hover resumes after preference change'] });
await respect.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('#abschluss-cta').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}`);
// With the default setting the note stays away: the site plays anyway, there is nothing to say.
await editor.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await editor.waitForTimeout(300);
assert.equal(await editor.locator('#motion-status').isVisible(), false, 'No motion note while the site ignores the preference');
await editor.close();
// The note appears once the site respects the preference - simulated in the preview frame.
// The preview frame carries the website's decision as a class on its <html>; set it there the
// way the template would, then let the preference change re-evaluate the note.
const editorRespect = await editorContext.newPage(); editorRespect.on('pageerror', error => errors.push(error.message));
await editorRespect.goto(`${origin}/admin/pages/${fixture.homeId}`);
await editorRespect.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await editorRespect.evaluate(() => document.querySelector('#preview').contentDocument.documentElement.classList.add('motion-respect'));
// Two toggles in one breath cancel out before the change event fires; give each its moment.
await editorRespect.emulateMedia({ reducedMotion: 'no-preference' });
await editorRespect.waitForTimeout(300);
await editorRespect.emulateMedia({ reducedMotion: 'reduce' });
await editorRespect.locator('#motion-status').waitFor({ state: 'visible' });
await editorRespect.screenshot({ path: `${report}/editor-motion-status.png` });
await editorRespect.emulateMedia({ reducedMotion: 'no-preference' });
await editorRespect.locator('#motion-status').waitFor({ state: 'hidden' });
await editorRespect.emulateMedia({ reducedMotion: 'reduce' });
await editorRespect.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();
}
+92
View File
@@ -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(); }
+166
View File
@@ -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(); }
+95
View File
@@ -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)'],
};
}
}
+33 -1
View File
@@ -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.');
+9 -2
View File
@@ -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:.');
}
}
}
+10
View File
@@ -29,6 +29,14 @@ final class SiteSettings
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
'stickyHeader' => false,
// Der Kopfbereich liegt über dem Inhalt statt darüber zu stehen - der Flatsome-Aufbau mit
// transparentem Kopf über dem Hero. Welche Farben er beim Scrollen bekommt, bestimmt die
// Gestaltung des Kopfbereichs selbst („beim Scrollen" unter Hintergrund).
'headerOverlay' => false,
// Einblendungen und Hover-Bewegungen. „always" spielt sie wie Flatsome bei jedem
// Besucher ab; „respect" hält sich an die Browservorgabe „Bewegung reduzieren", die
// manche Systeme melden, ohne dass jemand sie bewusst eingestellt hat.
'motion' => 'always',
// Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
'postMetaHeader' => true,
@@ -131,6 +139,7 @@ final class SiteSettings
}
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
if ('' !== $favicon) {
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
@@ -173,6 +182,7 @@ final class SiteSettings
throw new InvalidPage('Für die Turnstile-Prüfung werden beide Schlüssel gebraucht – der öffentliche und der geheime.');
}
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
$values['motion'] = in_array($input['motion'] ?? '', ['always', 'respect'], true) ? $input['motion'] : 'always';
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
// Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen
// Regeln je Bot besteht aus Bloecken, und ohne Trennung liest die niemand mehr. Das Limit
+1 -1
View File
@@ -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)
{
+14
View File
@@ -49,6 +49,14 @@ final class StyleCompiler
}
continue;
}
if ('scrolled' === $key) {
$this->require(is_array($value), 'Scrolled styles must be an object.');
foreach ($value as $scrolledKey => $scrolledValue) {
$this->require(in_array($scrolledKey, StyleSchema::scrollable(), true), sprintf('The property "%s" has no scrolled value.', (string) $scrolledKey));
$this->value($properties[$scrolledKey], $scrolledValue, (string) $scrolledKey);
}
continue;
}
$this->require(is_string($key) && isset($properties[$key]), sprintf('Unknown style property "%s".', (string) $key));
$this->value($properties[$key], $value, $key);
}
@@ -181,6 +189,12 @@ final class StyleCompiler
if ('' !== $hover) {
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
}
// The scrolled state: scrolled.js puts `is-scrolled` on <html> once the page has moved.
// The change eases in, so a header does not snap from glass to solid.
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($values['scrolled'], 'self', $device) : '';
if ('' !== $scrolled) {
$rules .= sprintf('#%s{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #%s{%s}', $id, $id, $scrolled);
}
if ('' === $rules) {
continue;
}
+10
View File
@@ -219,6 +219,16 @@ final class StyleSchema
return array_keys(array_filter(self::properties(), static fn (array $p): bool => $p['hover'] ?? false));
}
/**
* Properties that may also carry a value for the scrolled page - the Flatsome transparent
* header that turns solid once the reader has scrolled. Colours, shadow and opacity only:
* a header that changes size while scrolling would push the page around under the finger.
*/
public static function scrollable(): array
{
return ['backgroundColor', 'color', 'borderColor', 'shadowColor', 'opacity'];
}
public static function sides(): array
{
return ['top' => 'Oben', 'right' => 'Rechts', 'bottom' => 'Unten', 'left' => 'Links'];
+13 -4
View File
@@ -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));
}
/**
+4
View File
@@ -127,10 +127,14 @@ final class EditorController extends AbstractController
'sides' => StyleSchema::sides(),
'corners' => StyleSchema::corners(),
'hoverable' => StyleSchema::hoverable(),
'scrollable' => StyleSchema::scrollable(),
'colors' => $theme['colors'],
'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(),
]);
}
+7
View File
@@ -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;
}
+32 -4
View File
@@ -80,6 +80,9 @@ final class PageController extends AbstractController
$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));
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
// for the small script that marks the page as scrolled.
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
$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);
@@ -107,6 +110,7 @@ final class PageController extends AbstractController
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $needsSlider,
'needsMotion' => $needsMotion,
'needsScrolled' => $needsScrolled,
// 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)),
@@ -136,7 +140,7 @@ final class PageController extends AbstractController
// 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 = $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']));
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $needsScrolled || $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';
@@ -413,6 +417,27 @@ final class PageController extends AbstractController
return $gefunden;
}
/** Whether any block carries values for the scrolled page (see StyleSchema::scrollable). */
private function hasScrolled(array $document): bool
{
$walk = function (array $blocks) use (&$walk): bool {
foreach ($blocks as $block) {
foreach ($block['style'] ?? [] as $values) {
if ([] !== ($values['scrolled'] ?? [])) {
return true;
}
}
if ($walk($block['children'] ?? [])) {
return true;
}
}
return false;
};
return $walk($document['blocks'] ?? []);
}
private function hasEntrance(array $document): bool
{
$walk = function (array $blocks) use (&$walk): bool {
@@ -464,10 +489,13 @@ final class PageController extends AbstractController
}
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
$frames = $this->frameHosts($request, ...array_values($this->areas()));
$areas = $this->areas();
$frames = $this->frameHosts($request, ...array_values($areas));
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
return $this->shell($this->render('archive.html.twig', [
'areas' => $this->areas(),
'areas' => $areas,
'needsScrolled' => $needsScrolled,
'navigation' => $this->navigation(''),
'term' => $term,
'heading' => null === $term ? 'Beiträge' : $term['name'],
@@ -475,7 +503,7 @@ final class PageController extends AbstractController
'base' => $base,
'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''),
'navigation' => $this->navigation(''),
]), frameHosts: $frames);
]), $needsScrolled, frameHosts: $frames);
}
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
+4 -1
View File
@@ -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());
+13
View File
@@ -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,
);
}
}
+3 -18
View File
@@ -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);
}
}
+18 -2
View File
@@ -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;
}
}
+1 -1
View File
@@ -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', 'color-control', '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>
+70 -26
View File
@@ -4,47 +4,91 @@
{% 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
publication: saving is live, and the publish button is left out. #}
publication: saving is live, and the publish button is left out.
One sidebar, one canvas. The sidebar shows one thing at a time - the element library, the
page structure, the page settings or the options of the selected element - the way the
Flatsome UX Builder does it. Nothing competes for the eye. #}
<div id="editor" data-api="{{ area ? path('admin_partial_editor', {id: area.id}) : path('admin_document', {id: page.id}) }}" data-kind="{{ area ? area.kind : 'page' }}" data-can-script="{{ is_granted('ROLE_ADMIN') ? 'true' : 'false' }}" data-can-publish="{{ not area and is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
<header class="editor-header">
<a class="back-link" href="{{ path(listRoute) }}" aria-label="{{ area ? 'Zur Bereichsübersicht' : (page.type == 'post' ? 'Zur Beitragsübersicht' : 'Zur Seitenübersicht') }}">← <span>{{ area ? 'Bereiche' : (page.type == 'post' ? 'Beiträge' : 'Seiten') }}</span></a>
<a class="back-link" href="{{ path(listRoute) }}" aria-label="{{ area ? 'Zur Bereichsübersicht' : (page.type == 'post' ? 'Zur Beitragsübersicht' : 'Zur Seitenübersicht') }}"><span aria-hidden="true">←</span> <span>{{ area ? 'Bereiche' : (page.type == 'post' ? 'Beiträge' : 'Seiten') }}</span></a>
<div class="editor-page-name"><h1>{{ subject.title }}</h1><span>{{ area ? kinds[area.kind]|default('Bereich') : '/' ~ (page.slug == 'home' ? '' : page.slug) }}</span></div>
<div class="history-actions"><button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled>↶</button><button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled>↷</button></div>
<div class="save-actions"><span id="save-status" role="status" aria-live="polite">Wird geladen …</span><button id="save" class="secondary" disabled>{{ area ? 'Bereich speichern' : 'Entwurf speichern' }}</button>{% if not area and is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}</div>
<div class="viewport-controls" role="group" aria-label="Vorschaugröße">
<button type="button" data-viewport="desktop" aria-pressed="true" title="Desktop"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg><span>Desktop</span></button>
<button type="button" data-viewport="tablet" aria-pressed="false" title="Tablet"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="2" width="16" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Tablet</span></button>
<button type="button" data-viewport="mobile" aria-pressed="false" title="Mobil"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Mobil</span></button>
</div>
<div class="history-actions">
<button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg></button>
<button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg></button>
<button id="preview-motion" type="button" class="icon-button" aria-pressed="false" title="Bewegung in der Vorschau abspielen" aria-label="Bewegung in der Vorschau abspielen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 4 14 8-14 8z"/></svg></button>
</div>
<div class="save-actions">
<span id="save-status" role="status" aria-live="polite">Wird geladen …</span>
<button id="save" class="secondary" disabled>{{ area ? 'Bereich speichern' : 'Entwurf speichern' }}</button>
{% if not area and is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}
{% if not area %}<a class="icon-button" href="{{ page.path == 'home' ? '/' : '/' ~ page.path }}" target="_blank" rel="noopener" title="Öffentliche Seite öffnen" aria-label="Öffentliche Seite öffnen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9"/><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/></svg></a>{% endif %}
</div>
</header>
<div id="message" class="editor-message" role="alert" hidden></div>
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
<main id="admin-main" class="editor-layout" tabindex="-1">
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
<div class="section-launch"><button id="add-root-section" class="secondary full-width" disabled>+ Neue Sektion auf Seitenebene</button><p class="field-help">Nach dem aktuellen Abschnitt, außerhalb des gewählten Inhalts.</p></div>
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><p id="insert-hint" class="insert-hint" role="status" hidden>Einfügepunkt gesetzt — das nächste Element landet dort. <button id="cancel-insert" class="text-button" type="button">Abbrechen</button></p><h3 class="preset-heading">Element hinzufügen</h3>
<p class="panel-hint">Ziehen und in der Vorschau ablegen, oder klicken. Der Werkzeugkasten zeigt an, was an der gewählten Stelle hineinpasst — was dort nicht hingehört, sagt beim Überfahren warum.</p>
<label class="search-label" for="library-search">Element zum Einfügen suchen</label>
<input id="library-search" type="search" placeholder="z. B. Galerie, Zeile, Formular …" autocomplete="off">
<p id="library-found" class="field-help" role="status"></p>
<div id="library" class="library"></div>
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
<div id="presets" class="preset-library"><button data-preset="hero" disabled><span aria-hidden="true">▮</span> Vollbild-Hero</button><button data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
</div></section>
<section class="panel-section structure-section"><div class="panel-title"><h2>Struktur</h2><button id="select-page" class="text-button">Seite</button></div><label class="search-label" for="tree-search">Element auf der Seite suchen</label><input id="tree-search" type="search" placeholder="Text oder Elementtyp …"><div class="tree-tools"><button id="expand-all" class="text-button">Alle öffnen</button><button id="collapse-all" class="text-button">Einklappen</button></div><p class="panel-hint">Ziehen oder mit den Schaltflächen verschieben.</p><nav id="structure" aria-label="Blöcke auswählen"></nav>
<div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div>
</section>
<aside class="sidebar" aria-label="Bearbeitung" data-view="elements">
{# The top of the sidebar answers "where am I". Without a selection it is a small
set of tabs; with one it is the element's name, the way back up and the plus. #}
<div class="sidebar-head">
<div class="sidebar-nav" role="tablist" aria-label="Seitenleiste">
<button type="button" role="tab" data-view="elements" aria-selected="true">Elemente</button>
<button type="button" role="tab" data-view="structure" aria-selected="false">Struktur</button>
<button type="button" id="select-page" role="tab" data-view="page" aria-selected="false">Seite</button>
</div>
<div class="sidebar-element" hidden>
<button type="button" id="go-parent" class="icon-button" title="Zurück zum übergeordneten Element" aria-label="Zurück zum übergeordneten Element"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 4-8 8 8 8"/></svg></button>
<div class="sidebar-element-name"><h2 id="properties-title">Element</h2><nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav></div>
<button type="button" id="add-here" class="icon-button" title="Element hinzufügen" aria-label="Element hinzufügen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
<button type="button" id="close-element" class="icon-button" title="Auswahl aufheben (Escape)" aria-label="Auswahl aufheben"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</div>
<div class="property-tabs" role="group" aria-label="Eigenschaftenbereich" hidden><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div>
</div>
<div class="sidebar-body">
<section class="sidebar-view" data-view="elements" id="toolbox">
<p id="insert-hint" class="insert-hint" role="status" hidden>Einfügepunkt gesetzt — das nächste Element landet dort. <button id="cancel-insert" class="text-button" type="button">Abbrechen</button></p>
<label class="visually-hidden" for="library-search">Element suchen</label>
<input id="library-search" type="search" placeholder="Element suchen …" autocomplete="off">
<p id="library-found" class="field-help" role="status"></p>
<div id="library" class="library"></div>
<h3 class="library-group-title">Fertige Abschnitte</h3>
<div id="presets" class="preset-library"><button type="button" data-preset="hero" disabled><span aria-hidden="true">▮</span> Vollbild-Hero</button><button type="button" data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button type="button" data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button type="button" data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
<button id="open-media-library" type="button" class="secondary full-width media-launch" disabled>Mediathek öffnen</button>
</section>
<section class="sidebar-view" data-view="structure" hidden>
<label class="visually-hidden" for="tree-search">Element auf der Seite suchen</label>
<input id="tree-search" type="search" placeholder="Auf der Seite suchen …">
<div class="tree-tools"><button id="expand-all" class="text-button" type="button">Alle öffnen</button><button id="collapse-all" class="text-button" type="button">Einklappen</button><button id="nest" class="text-button" type="button" title="In vorherigen Container verschachteln">⇥ Einrücken</button><button id="outdent" class="text-button" type="button" title="Eine Ebene herausheben">⇤ Ausrücken</button></div>
<nav id="structure" aria-label="Blöcke auswählen"></nav>
</section>
<section class="sidebar-view" data-view="element" hidden>
<div id="properties"><p class="muted">Inhalte werden geladen …</p></div>
</section>
</div>
<div class="sidebar-foot block-actions" hidden>
<button id="move-up" type="button" class="icon-button" title="Nach oben verschieben" aria-label="Nach oben verschieben"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
<button id="move-down" type="button" class="icon-button" title="Nach unten verschieben" aria-label="Nach unten verschieben"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7"/></svg></button>
<button id="duplicate" type="button" class="secondary">Duplizieren</button>
<button id="delete" type="button" class="secondary danger-text">Löschen</button>
</div>
<p id="motion-status" class="sidebar-note" role="status" hidden>Dein Browser meldet „Bewegung reduzieren“. Die Website spielt Einblendungen trotzdem ab, solange unter Einstellungen nicht „Systemvorgabe beachten“ gewählt ist.</p>
</aside>
<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="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>
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
</section>
<div class="panel-resizer" data-resize="right" role="separator" tabindex="0" aria-label="Rechte Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="280" aria-valuemax="560" aria-valuenow="320" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">{{ area ? 'Speichern aktualisiert diesen Bereich auf allen Seiten, die ihn verwenden.' : 'Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.' }}</div></aside>
</main>
<div id="context-menu" class="context-menu" role="menu" aria-label="Elementmenü" hidden></div>
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
<dialog id="preset-dialog" aria-labelledby="preset-title"><h2 id="preset-title">Gestaltung als Vorlage speichern</h2><p>Die Vorlage steht danach für jeden Block auf jeder Seite zur Verfügung.</p><label for="preset-name">Name der Vorlage</label><input id="preset-name" maxlength="60" placeholder="Heller Abschnitt"><p id="preset-error" class="field-error" hidden></p><div class="dialog-actions"><button id="cancel-preset" class="secondary">Abbrechen</button><button id="confirm-preset" class="primary">Vorlage speichern</button></div></dialog>
+2 -1
View File
@@ -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">
+1
View File
@@ -0,0 +1 @@
{% for icon in menuIcons %}<template id="menu-icon-{{ icon }}">{% include 'blocks/icon.html.twig' with {name: icon} only %}</template>{% endfor %}
+2 -1
View File
@@ -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>
+8
View File
@@ -165,6 +165,14 @@
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>, <code>Allow:</code>, <code>User-agent:</code> und <code>Crawl-delay:</code>. Leerzeilen trennen Blöcke, höchstens 200 Zeilen. Hier stehen zum Beispiel eigene Regeln je Bot – etwa welche KI-Crawler die Website lesen dürfen und welche nicht.</p>
<div class="section-head"><h2>Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
<label class="check-label" for="stickyHeader"><input id="stickyHeader" name="stickyHeader" type="checkbox" value="1"{{ settings.stickyHeader ? ' checked' : '' }}> Kopfbereich beim Scrollen oben halten</label>
<label class="check-label" for="headerOverlay"><input id="headerOverlay" name="headerOverlay" type="checkbox" value="1"{{ settings.headerOverlay ? ' checked' : '' }}> Kopfbereich über dem Inhalt schweben lassen</label>
<p class="field-help">Der Kopfbereich liegt dann über der ersten Sektion, wie der transparente Header in Flatsome. Seine Farben für den gescrollten Zustand stellst du im Editor des Kopfbereichs unter Design → Hintergrund „beim Scrollen“ ein; zusammen mit „oben halten“ bleibt er beim Scrollen sichtbar.</p>
<label for="motion">Einblendungen und Hover-Bewegungen</label>
<select id="motion" name="motion">
<option value="always"{{ settings.motion == 'always' ? ' selected' : '' }}>Immer abspielen (wie Flatsome)</option>
<option value="respect"{{ settings.motion == 'respect' ? ' selected' : '' }}>Browservorgabe „Bewegung reduzieren“ beachten</option>
</select>
<p class="field-help">Manche Browser melden „Bewegung reduzieren“, ohne dass jemand es eingestellt hat – dann bleibt bei „beachten“ jede Einblendung aus. „Immer abspielen“ verhält sich wie andere Websites; die Slider-Automatik hält an, sobald jemand sie berührt.</p>
<label class="check-label" for="postMetaHeader"><input id="postMetaHeader" name="postMetaHeader" type="checkbox" value="1"{{ settings.postMetaHeader ? ' checked' : '' }}> Datum, Autor und Kategorie über einem Beitrag zeigen</label>
<label class="check-label" for="consentBanner"><input id="consentBanner" name="consentBanner" type="checkbox" value="1"{{ settings.consentBanner ? ' checked' : '' }}> Einwilligungshinweis anzeigen</label>
<p class="field-help">Ohne eingebettete Inhalte anderer Anbieter braucht diese Website keine Einwilligung – dann ist ein Banner nur im Weg und sollte aus bleiben. Eingebettete Inhalte laden ohnehin erst nach Zustimmung, unabhängig von diesem Schalter.</p>
+1 -1
View File
@@ -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') }}">
+18
View File
@@ -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>
+4 -2
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="de">
<html lang="de"{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -17,13 +17,15 @@
<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() }}">
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
</head>
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
{% if areas.header|default(null) %}
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}">
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
</header>
{% endif %}
+17 -12
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+1
View File
@@ -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>
+3 -3
View File
@@ -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 %}
+5 -2
View File
@@ -1,5 +1,6 @@
<!doctype html>
<html lang="{{ page.locale }}">
{# Ohne Klasse spielt die Website Bewegung immer ab; mit ihr gilt die Browservorgabe. #}
<html lang="{{ page.locale }}"{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -36,6 +37,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 %}
@@ -49,6 +51,7 @@
{% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsSlider|default(false) %}<script type="module" src="/assets/slider.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsZoom|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
{# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must
show the layout, not run code that could change the page under the editor's hands. #}
@@ -71,7 +74,7 @@
{# Header and footer are block documents like any page, so they are built from elements
instead of being written into this template. #}
{% if areas.header|default(null) %}
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}">
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null), inHeader: true} only %}{% endfor %}
</header>
{% endif %}
+2
View File
@@ -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'],
+43 -14
View File
@@ -574,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);
}
@@ -1686,15 +1687,16 @@ final class CmsTest extends TestCase
$html = $this->request('/')->getContent();
self::assertStringContainsString('<span class="post-view" aria-hidden="true">Ansehen</span>', $html);
// Die Bewegung steht vollständig hinter der Bewegungsschranke, der Hinweis selbst nicht:
// wer weniger Bewegung eingestellt hat, sieht ihn trotzdem.
// Die Bewegung spielt für alle; beachtet die Website die Browservorgabe, nimmt ein
// Überschreibblock hinter der Bewegungsschranke nur die Fahrt weg, der Hinweis bleibt.
$effects = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
// Der erste Abschnitt steht vor der ersten Schranke; alles dahinter ist geschützt.
$guarded = explode('@media (prefers-reduced-motion: no-preference) {', $effects);
$geschuetzt = implode('', array_slice($guarded, 1));
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $geschuetzt);
self::assertStringContainsString('transform: scale(1.06)', $geschuetzt);
self::assertStringNotContainsString('transform: translateY(-8px)', $guarded[0], 'Keine Bewegung außerhalb der Schranke.');
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $effects);
self::assertStringContainsString('transform: scale(1.06)', $effects);
$schranke = explode('@media (prefers-reduced-motion: reduce) {', $effects);
self::assertCount(2, $schranke, 'Genau ein Überschreibblock für „Bewegung reduzieren“.');
self::assertStringContainsString('html.motion-respect', $schranke[1]);
self::assertStringContainsString('transform: none', $schranke[1]);
self::assertStringNotContainsString('html.motion-respect', $schranke[0], 'Ohne Schranke gilt die Klasse nicht.');
// Und über die Tastatur erreichbar: der Zustand hängt auch an :focus-within.
$site = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
@@ -2230,6 +2232,31 @@ final class CmsTest extends TestCase
self::assertStringContainsString('@media (max-width:767px){#start-hero{font-size:28px', $html);
}
public function testAHeaderCanChangeItsColoursOnceThePageHasScrolled(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', self::styledDocument(), 0), 1);
$header = $this->partials->findByKind('header');
$document = $header['document'];
// Glass over the hero, solid once the reader has moved: the Flatsome transparent header.
$document['blocks'][0]['style'] = ['desktop' => ['backgroundColor' => '#ffffff00', 'scrolled' => ['backgroundColor' => '#ffffff', 'color' => '#123456']]];
$this->partials->save($header['id'], $document, (int) $header['version']);
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('#area-header{background-color:#ffffff00}', $html);
self::assertStringContainsString('#area-header{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #area-header{background-color:#ffffff;color:#123456}', $html);
// The small script that marks the scrolled page comes only with such a header, and the
// policy opens for it exactly then.
self::assertStringContainsString('/assets/scrolled.js?v=', $html);
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
// Nur Farben, Schatten und Deckkraft dürfen sich beim Scrollen ändern; eine wachsende
// Schrift würde die Seite unter dem Finger verschieben.
$document['blocks'][0]['style']['desktop']['scrolled'] = ['fontSize' => '40px'];
$this->expectException(InvalidDocument::class);
$this->partials->save($header['id'], $document, (int) $header['version'] + 1);
}
public function testInlineStylesheetIsAllowedByItsOwnHashAndNothingElse(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', self::styledDocument(), 0), 1);
@@ -2569,7 +2596,7 @@ final class CmsTest extends TestCase
self::assertStringContainsString('<header class="site-header">', $html);
self::assertStringContainsString('<p id="area-header-name" class="block-text">kantsites</p>', $html);
self::assertStringContainsString('aria-label="Hauptnavigation"', $html);
self::assertStringContainsString('>Start</a>', $html);
self::assertStringContainsString('<span class="nav-label"><span>Start</span></span>', $html);
// Changing the header reaches every page at once, because it is one shared document.
$header = $this->partials->findByKind('header');
@@ -2699,7 +2726,8 @@ final class CmsTest extends TestCase
self::assertSame('_blank', $crawler->filter($selector)->attr('target'));
self::assertSame('noopener', $crawler->filter($selector)->attr('rel'));
}
self::assertCount(2, $crawler->filter('.site-header .menu-list a[target="_blank"][rel="noopener"]'));
self::assertCount(2, $crawler->filter('.site-header .block-menu > .menu-list a[target="_blank"][rel="noopener"]'));
self::assertCount(2, $crawler->filter('.site-header .menu-burger a[target="_blank"][rel="noopener"]'));
$document['blocks'][array_key_last($document['blocks'])]['props']['newTab'] = 'yes';
$this->expectException(InvalidDocument::class);
(new DocumentValidator())->validate($document);
@@ -3295,7 +3323,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'));
}
+2 -1
View File
@@ -287,7 +287,7 @@ final class EditorSecurityTest extends WebTestCase
// The blank row is an add slot, not an entry.
self::assertCount(2, $items);
$public = $client->request('GET', '/')->filter('nav[aria-label="Hauptnavigation"] a');
$public = $client->request('GET', '/')->filter('nav[aria-label="Hauptnavigation"] > .menu-list a');
// The navigation comes from the menu now, not from the links hard-coded in the template.
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
}
@@ -367,6 +367,7 @@ final class EditorSecurityTest extends WebTestCase
$html = $client->request('GET', '/')->html();
self::assertStringContainsString('action="/formular/kontakt"', $html);
self::assertStringContainsString('<button class="button cms-button" type="submit">Absenden</button>', $html);
self::assertStringContainsString('Ich willige ein.', $html);
// The honeypot exists but is kept out of the tab order and away from assistive technology.
self::assertStringContainsString('name="website"', $html);
+38
View File
@@ -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);
}
}
}
+47
View File
@@ -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());
}
}
}
}
+18
View File
@@ -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'));
});