Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160c394c3f | ||
|
|
a4ee04533c | ||
|
|
fb7cfa37d1 | ||
|
|
0aa1ff6d3e | ||
|
|
65b11563a8 | ||
|
|
0bdcc7f90e | ||
|
|
860bed9199 | ||
|
|
1973bd10ef | ||
|
|
a434e0cefc | ||
|
|
858b8c6de4 | ||
|
|
5f7ea63b76 | ||
|
|
3cb5b03dd9 | ||
|
|
73719bae4a | ||
|
|
215ab064ae | ||
|
|
042aef9a32 | ||
|
|
a19b6cc1c9 | ||
|
|
fd4cb75548 | ||
|
|
30e4b3eb2e | ||
|
|
cef710d5db | ||
|
|
434e89f779 | ||
|
|
6dc30a43fe | ||
|
|
9095aa5d2c | ||
|
|
769b0fae2d | ||
|
|
9c8462d46d | ||
|
|
3785d3dfef | ||
|
|
b634f486e5 | ||
|
|
2299184401 | ||
|
|
a1f8b905cd | ||
|
|
ebc16777d6 | ||
|
|
7c1c86b864 | ||
|
|
c82a37643b | ||
|
|
cff468167b | ||
|
|
f182f9e125 | ||
|
|
37c2942d1d | ||
|
|
eea6747d9e | ||
|
|
3391fea58b | ||
|
|
bb4e422af8 | ||
|
|
81276ddd1a | ||
|
|
d300eae241 |
No files matched your search
@@ -18,6 +18,46 @@
|
||||
"bin/dev-router.php"
|
||||
],
|
||||
"port": 8080
|
||||
},
|
||||
{
|
||||
"name": "kantsites-pilot",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-539a85bd3015/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8087 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8087
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/elektro-bernhard.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/elektro-bernhard-media'; $env:SITE_ORIGIN='http://127.0.0.1:8088'; & php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8088 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8088
|
||||
},
|
||||
{
|
||||
"name": "tagespflege-moldovan",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/tagespflege-moldovan.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/tagespflege-moldovan-media'; $env:SITE_ORIGIN='http://127.0.0.1:8089'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8089 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8089
|
||||
},
|
||||
{
|
||||
"name": "kantit",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantit.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; $env:SITE_ORIGIN='http://127.0.0.1:8130'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8130 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8130
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,126 @@ 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`.
|
||||
|
||||
## Formulare, Statistik, Design und Seitenleiste überarbeitet (ab 1.15.0)
|
||||
|
||||
**Formulare** sind ein Editor: jedes Feld eine Karte mit Nummer, Beschriftung, Typ, technischem Namen, Hilfetext und Pflicht-Schalter, dazu „Feld hinzufügen“ und ein Entfernen-Knopf je Karte. Der technische Name folgt der Beschriftung, bis du ihn selbst schreibst. Unter **Aussehen** bekommt jedes Formular eine Buttonbeschriftung sowie Rahmen- und Hintergrundfarbe der Felder, Button- und Buttonschriftfarbe – aus der Palette oder als eigener Wert, leer heißt „wie die Website“. Die Vorschau daneben folgt jeder Eingabe, zeichnet den Absendeknopf mit dem Standardbutton des Designs und nennt den Kontrast der Buttonschrift.
|
||||
|
||||
**Die Statistik** beginnt mit Kennzahlen: Aufrufe mit Vergleich zum gleich langen Zeitraum davor, Schnitt je Tag, stärkster Tag, aufgerufene Seiten, Aufrufe über Verweise, nicht gefundene Adressen. Der Verlauf hat Rasterlinien, jeder Balken nennt beim Überfahren Tag und Zahl, der stärkste Tag ist hervorgehoben; neu ist ein Wochentagsdiagramm im Schnitt. Die Ranglisten tragen Rang und Anteilsbalken.
|
||||
|
||||
**Im Design** ist die Palette eine Reihe von Farbkarten, deren Fläche der Farbwähler selbst ist. Sieben fertige Paletten (Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht) lassen sich per Klick übernehmen; eigene Farben bleiben stehen. Text-, Seiten- und Linkfarbe wählst du aus der Palette oder trägst einen eigenen Wert ein. Jede Schrift zeigt eine Probe in sich selbst, die Schnitte sind Chips, und eine Typografie-Vorschau zeigt Schrift, Größe, Zeilenhöhe, Skala, Farben und Unterstreichung live.
|
||||
|
||||
**Die Seitenleiste** endet in einer Kontokarte mit Avatar, Name, Rolle und Abmeldesymbol, darunter die Fassung mit dem Update-Hinweis. Auf Tablets bleiben Konto und Abmelden rechts im Bild, nur die Liste wischt.
|
||||
|
||||
## Fußzeile des Einwilligungshinweises (ab 1.14.2)
|
||||
|
||||
„Einzeln wählen“ steht links, Datenschutz und Impressum rechts auf einer Linie; aufgeklappt nimmt die Einzelwahl die volle Breite, die Links rutschen rechtsbündig darunter.
|
||||
|
||||
## Einwilligungshinweis gestalten, Nach-oben-Knopf auch bei offener Frage (ab 1.14.1)
|
||||
|
||||
**Der Einwilligungshinweis lässt sich gestalten**, unter **Einstellungen → Einwilligung → Aussehen des Hinweises**: Anordnung (Karte unten in der Mitte, Leiste über die volle Breite, kleine Karte rechts unten), Form (abgerundet, rund, eckig – gilt für Fläche und Knöpfe zusammen), eigene Überschrift und vier Farben (Fläche, Schrift, Knopf „Alle akzeptieren“, Schrift auf dem Knopf), jeweils als `#hex` oder `@schlüssel` aus der Palette. Leer heißt Papier, Tinte und Akzent aus dem Design. Die Fläche trägt einen schmalen Streifen und ein Symbol in der Knopffarbe; „Nur notwendige“ bleibt ein gleich großer Rahmenknopf daneben. Dieselben Farben gelten auf `/einwilligung` und in den Beitragsarchiven.
|
||||
|
||||
**Der Nach-oben-Knopf** war ausgeblendet, solange die Einwilligungsfrage offen stand – auf dem Telefon bleibt sie das oft, und der Knopf kam dort nie. Jetzt setzt er sich auf die Oberkante der Leiste, wo sie die Ecke abdeckt; Browser ohne Ankerpositionierung blenden ihn dort weiter aus. Der reservierte Abstand unten reicht auf schmalen Bildschirmen wieder für die ganze Leiste.
|
||||
|
||||
**Im Designbereich** sind belegte Eigenschaften jetzt in Orange beschriftet, nicht nur mit Punkt und Fettdruck; die Gerätechips D/T/M stehen weiter auseinander, und der Fokusring bleibt in der Lücke.
|
||||
|
||||
## Schriften vom eigenen Server, Hinweise auf jedem Bildschirm (ab 1.14.0)
|
||||
|
||||
**Schriften kommen ausschließlich von deinem Server.** Die Ausgabe verlinkt kein Stylesheet mehr von `fonts.googleapis.com` – weder die öffentliche Seite noch die Editorvorschau, die es bisher bei jedem Aufruf tat. Fehlen die Dateien, zeigt die Website die eingestellte Ersatzschrift und sagt es dir. Geholt werden sie wie bisher einmalig unter **Design → Schriften ausliefern**. Zusätzlich wird jetzt **jeder** Schnitt des Designs vorab geladen: bisher waren es die ersten zwei je Familie, weshalb die Überschriften sichtbar von der Ersatzschrift auf die richtige Schrift sprangen.
|
||||
|
||||
**Offene Hinweise stehen auf jedem Verwaltungsbildschirm**, mit dem Knopf daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren*, „Schriftdateien fehlen“ → *Schriften holen*. Keiner der beiden fragt beim Öffnen im Netz nach.
|
||||
|
||||
**System und Update** ist auf dem Telefon benutzbar: Die Seite lief seitlich aus dem Bild, die Sicherungstabelle wird zu gestapelten Blöcken, und die Texte sind knapper – das Kleingedruckte steht zugeklappt.
|
||||
|
||||
**Im Designbereich** zeigen die Gerätechips **D/T/M** jetzt in Orange, welches Gerät einen eigenen Wert trägt; das gerade bearbeitete Gerät hat einen Rahmen statt des bisherigen roten Rings, der am Rand der Spalte abgeschnitten wurde.
|
||||
|
||||
## Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (ab 1.14.0)
|
||||
|
||||
Unter **Einstellungen → Unternehmensdaten** gibt es **Öffnungszeiten** (je Wochentag Öffnen und Schließen; leer heißt geschlossen) und das **Einsatzgebiet** (ein Ort je Zeile). Beides erscheint in den strukturierten Daten der Startseite (`openingHoursSpecification`, `areaServed`); gleiche Zeiten werden zu einer Angabe zusammengefasst. Der sichtbare Text auf der Website bleibt unabhängig davon.
|
||||
|
||||
Ein Akkordeon wird per Kästchen **Als FAQ auszeichnen** zur `FAQPage`: die Titel sind die Fragen, die Textbausteine im Eintrag die Antwort. Einträge ohne Text erscheinen nicht. Das gilt nur für Akkordeons mit gesetztem Kästchen – ein Akkordeon mit Leistungen bleibt ein Akkordeon.
|
||||
|
||||
## Mehr Symbole, Menü-Knopf gestalten, Nach-oben-Knopf zuverlässig (ab 1.13.0)
|
||||
|
||||
Der Symbolsatz hat drei Zeichnungen mehr: **Menschen**, **Musik** und **Haus**. Sie stehen überall zur Wahl, wo es Symbole gibt – Symbolbaustein, Symbolkarte und Menüeinträge. Eine am Symbolbaustein eingestellte **Farbe** wirkt jetzt auch wirklich auf die Zeichnung; bisher blieb sie in der Akzentfarbe der Website stecken.
|
||||
|
||||
Der **Menü-Knopf** (der aufklappbare „Menü“-Knopf auf Tablet und Telefon) lässt sich gestalten: Navigation auswählen → **Inhalt → Menü-Knopf** wählt zwischen *Symbol und Text*, *Nur Text* und *Nur Symbol*; „Nur Symbol“ behält den Namen für Vorlesesoftware. Unter **Design → Menü-Knopf** stehen Schriftfarbe, Fläche, Rahmenfarbe, Rahmenstärke, Ecken und Schriftgröße, je Gerät und mit Palettenfarben. Das aufgeklappte Menü legt sich im Kopfbereich als Fläche über die volle Breite unter den Kopf, statt die Kopfzeile auseinanderzuschieben.
|
||||
|
||||
Der **Nach-oben-Knopf** erscheint wieder überall. Er blendete sich über eine Scroll-Animation ein; Browser, die diese Eigenschaft zwar kennen, aber nicht ausführen, ließen ihn dauerhaft unsichtbar. Jetzt entscheidet eine Klasse, die das kleine Scroll-Skript setzt – und ohne JavaScript bleibt der Knopf einfach sichtbar.
|
||||
|
||||
## Soziale Profile (ab 1.12.0)
|
||||
|
||||
Unter **Einstellungen → Unternehmensdaten → Soziale Profile** je Netzwerk eine Adresse eintragen (Facebook, Instagram, LinkedIn, YouTube, TikTok, X, Threads, Pinterest, Xing, WhatsApp, Google-Unternehmensprofil). Das Element **Soziale Profile** (Werkzeugkasten → Bausteine) zeigt sie dann an jeder Stelle – im Kopf- oder Fußbereich, in einer Kontaktsektion – und bestimmt dort Größe, Form (nur Symbol, runde oder eckige Fläche), Anordnung, Beschriftung und ob nur bestimmte Netzwerke erscheinen. Farbe, Abstand und Ausrichtung wie bei jedem Element unter **Design**. Eine Adresse ändern heißt: überall geändert. Die Profile stehen automatisch in den strukturierten Daten (`sameAs`). Ohne Eintrag zeigt das Element auf der Website nichts.
|
||||
|
||||
## Slider-Steuerung auf dem Bild (ab 1.11.4)
|
||||
|
||||
Die Steuerung liegt jetzt auf dem Slider, wie beim Hero-Slider von kant-it.de: runde weiße Pfeile links und rechts in der Mitte, Punkte unten rechts mit dem aktiven Punkt als Strich in der Akzentfarbe, ein kleiner Pause-Knopf daneben, kein Zähler mehr (der bleibt für Vorlesesoftware). Auf Bildschirmen bis 600 px verschwinden die Pfeile, gewischt wird mit dem Finger. Unter **Design → Slider-Steuerung** gibt es zusätzlich „Aktiver Punkt“.
|
||||
|
||||
## Farben der Slider-Steuerung (ab 1.11.3)
|
||||
|
||||
Slider auswählen → **Design → Slider-Steuerung**: Farbe der Punkte, Farbe von Pfeilen und Zähler sowie Hintergrund der Knöpfe, je Gerät und mit Palettenfarben. Ohne eigene Werte sind die Punkte halbtransparent weiß und die Pfeile weiß mit Akzentfarbe. Die Farben gelten auch für die skriptfreien Punkte, die der Browser selbst zeichnet.
|
||||
|
||||
## 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 +150,10 @@ 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/`.
|
||||
|
||||
**Farbauswahl im Editor (ab 1.8.2):** Unter Design zeigt jedes Farbfeld eine Vorschau mit Palettenname und Hexwert. Anklicken öffnet die Website-Farben als Farbkacheln. Eigene Farben über den Farbwähler oder als Hexwert eingeben und mit Enter/✓ übernehmen. Transparente Hexfarben werden auf einem Schachbrett dargestellt. „Geerbten Wert verwenden“ entfernt die eigene Überschreibung für das aktive Gerät. Prüfen: `npm run test:colors` mit isolierten Kant-IT-Daten; Screenshots unter `reports/color-picker/`.
|
||||
@@ -56,7 +180,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.
|
||||
|
||||
@@ -151,6 +275,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.
|
||||
@@ -181,6 +309,18 @@ Beim ersten Start von 1.7.0 wird eine vorhandene `.env.local` einmalig mit `.env
|
||||
|
||||
Weitere Symfony-Overlays (`.env.prod`, `.env.prod.local`, `.env.local.php`) werden nicht mehr automatisch geladen. Falls ein Hosting sie bewusst verwendet hat, deren gewünschte Werte vor dem Wechsel in `.env` oder echte Servervariablen übernehmen. Der Installer, Setup und der Admin-Pfad-Befehl erzeugen keine `.env.local` mehr. Rückweg: alten Code und die beiden gesicherten Konfigurationsdateien gemeinsam wiederherstellen.
|
||||
|
||||
### Zweite lokale Referenzwebsite: Elektro Bernhard
|
||||
|
||||
Neben dem Kant-IT-Piloten liegt eine vollständige Kundenwebsite in einer eigenen Installation: Datenbank `var/elektro-bernhard.db`, Medien in `var/elektro-bernhard-media`. Beide werden über Umgebungsvariablen gewählt, die Anwendung selbst bleibt unverändert:
|
||||
|
||||
```
|
||||
DATABASE_URL="sqlite:///<Projektpfad>/var/elektro-bernhard.db" MEDIA_DIRECTORY="<Projektpfad>/var/elektro-bernhard-media" php -S 127.0.0.1:8088 -t public bin/dev-router.php
|
||||
```
|
||||
|
||||
In der App-Vorschau erledigt das die Startkonfiguration `elektro-bernhard` in `.claude/launch.json` (Port 8088).
|
||||
|
||||
Der Bestand wird nicht von Hand gepflegt, sondern aufgebaut: `scripts/seed-elektro-bernhard.php <Ordner mit den Originalbildern>` legt Gestaltung, Medien, Formular, Kopf- und Fußbereich, elf Seiten, vier Menüs, Einstellungen, Weiterleitungen und ein Administratorkonto an. Das Skript weigert sich, wenn die Datenbank schon Seiten enthält oder `DATABASE_URL` nicht auf diese Installation zeigt. `sh scripts/reset-elektro-bernhard.sh <Bilderordner>` wirft die Installation weg, migriert neu und baut sie wieder auf; das Passwort des Kontos steht am Ende der Ausgabe. Vorlage ist das statische Paket `Elektro-Bernhard-Website`; die drei Stellen, an denen der Nachbau bewusst abweicht, stehen in `docs/STATUS.md`.
|
||||
|
||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||
|
||||
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||
+109
-2
@@ -1,6 +1,111 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 097 – Der Einwilligungshinweis bekommt ein Gesicht, und der Nach-oben-Knopf weicht ihm aus (15.09.2026)
|
||||
|
||||
**Anlass.** Umut gefiel das Banner nicht: nicht gestaltbar, ein kantiger Kasten in Papierfarbe. Und der Nach-oben-Knopf „funktionierte auf dem Handy nicht“ – er war ausgeblendet, solange die Frage offen stand, und auf dem Telefon bleibt sie das oft.
|
||||
|
||||
**Gestaltung aus den Einstellungen, nicht aus dem Editor.** Der Hinweis ist kein Baustein einer Seite, sondern eine Frage der Website; seine Gestaltung gehört deshalb neben seinen Text unter Einstellungen → Einwilligung: Anordnung (`karte`, `leiste`, `ecke`), Form (`weich`, `rund`, `eckig`), Überschrift, vier Farben (Fläche, Schrift, Knopf, Schrift auf dem Knopf). Die Farben nehmen `#hex` oder `@schlüssel` aus der Palette wie der Nach-oben-Knopf; `Theme::cssColor()` löst beides für beide auf. Ausgegeben wird das als `:root{--kant-consent-…}` im gehashten Stylesblock (`Consent::styles()`), die Anordnung als Klasse. Die Form setzt zwei Radien zugleich – Fläche und Knöpfe gehören zusammen; zwei Regler dafür wären zwei, die man gegeneinander verstellt. Leer heißt weiterhin Papier, Tinte und Akzent des Themes.
|
||||
|
||||
**Was gleich bleibt, mit Absicht.** „Nur notwendige“ bleibt ein Rahmenknopf in der Schriftfarbe, genauso groß wie „Alle akzeptieren“, in derselben Zeile – die Farbe des Knopfes ist einstellbar, die Gleichwertigkeit der Antworten nicht. Kein Skript, keine Cookie-Wand.
|
||||
|
||||
**Archiv und Einwilligungsseite.** Beide trugen bisher keine Palette im Kopf. Sie bekommen jetzt Theme- und Hinweisvariablen als gehashten Stylesblock; die Einwilligungsseite erweitert ihre Richtlinie um genau diesen einen Hash.
|
||||
|
||||
**Der Nach-oben-Knopf.** Statt `display:none` bei offener Frage setzt er sich per Ankerpositionierung auf die Oberkante der Leiste (`anchor-name`, `bottom:calc(anchor(top) + 12px)`), wo sie die Ecke abdeckt: bis 799 px bei der Karte, auf jeder Breite bei Leiste und Ecke (`:has()`). Browser ohne Ankerpositionierung blenden ihn dort weiter aus. Damit die Leiste als Anker taugt, steht sie in `page.html.twig` vor dem Knopf. Der reservierte Abstand unten wächst auf 320 px bis 620 px, weil die Leiste dort mit Standardtext bis 290 px hoch ist.
|
||||
|
||||
## ADR 096 – Schriften ausschließlich vom eigenen Server (15.09.2026)
|
||||
|
||||
**Entscheidung.** Die Ausgabe verlinkt kein Stylesheet von `fonts.googleapis.com` mehr, in keinem Pfad. Liegen die Schriftdateien lokal, liefert die Website sie selbst; liegen sie nicht vor, zeigt sie die eingestellte Ersatzschrift. `Theme::googleFontsUrl()` entfällt, und die Richtlinien nennen keinen fremden Schrifthost mehr – öffentlich `font-src 'self'`, in der Verwaltung zusätzlich `style-src 'self'`. Das Herunterladen der Dateien bleibt, wo es hingehört: ein Knopf im Designbereich beziehungsweise `cms:fonts:fetch`, einmalig und von einem Menschen ausgelöst.
|
||||
|
||||
**Warum.** ADR 037ff. hatten den Verweis auf Google als ausdrückliche Entscheidung Umuts stehen lassen, mit dem Selbsthosten als offenem Weg. Seit die Dateien lokal liegen können, ist der Rückfallpfad nur noch eine Falle: er greift genau dann, wenn niemand hinsieht, und gibt dann die IP-Adresse jedes Besuchers an einen Dritten weiter (LG München I, 3 O 17493/20). Vor allem betraf er die Editorvorschau, die `googleFonts` und **kein** `localFonts` bekam und die Schriften deshalb bei jedem Aufruf von Google holte – auf dem Bildschirm, an dem am meisten gearbeitet wird.
|
||||
|
||||
**Folge.** Eine Installation, deren Schriften fehlen, sieht anders aus als vorher: Ersatzschrift statt Wunschschrift. Das ist beabsichtigt und sichtbar gemacht – der Designbereich nennt die fehlenden Schnitte, und seit derselben Fassung sagt es ein Hinweisstreifen auf jedem Verwaltungsbildschirm mit dem Knopf daneben. Ein stilles „lädt halt von Google“ wäre die schlechtere Überraschung.
|
||||
|
||||
**Und das Vorabholen.** Es holte je Familie die ersten zwei Schnitte. Das Kant-IT-Theme nennt Lato 400, 700 und 900, die Überschriften stehen auf 800/900 und landen damit auf dem einzigen Schnitt ohne Vorabholen: er lud erst nach dem Stylesheet, und `font-display:swap` tauschte die Überschrift sichtbar. Jetzt wird jeder Schnitt des Themes vorab geholt, im lateinischen Grundbereich, und die Verweise stehen vor dem Stylesheet. Gemessen: alle Dateien sind vor dem ersten Zeichnen da.
|
||||
|
||||
## ADR 095 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (14.09.2026)
|
||||
|
||||
`LocalBusiness` nannte Name, Adresse, Telefon, E-Mail und Profile – nicht, wann jemand erreichbar ist und wo er arbeitet. Beides sind Angaben der Website, nicht einer Seite, deshalb stehen sie in den Einstellungen: `openingHours` als Liste `{day, opens, closes}` je Wochentag (Formular: sieben Zeilen mit Uhrzeitfeldern, leer heißt geschlossen; geprüft als `HH:MM`, Schließen nach Öffnen) und `areaServed` als Liste von Ortsnamen (ein Ort je Zeile, höchstens vierzig). `organisation()` fasst Tage mit gleichen Zeiten zu einer `OpeningHoursSpecification` zusammen – Mo–Fr 07:30–17:00 ist eine Angabe, nicht fünf – und gibt `areaServed` als Namensliste aus. Der Text auf der Website bleibt davon unabhängig; niemand soll zwei Wahrheiten pflegen müssen, aber das CMS erzwingt keine.
|
||||
|
||||
Eine FAQ ist dagegen Inhalt einer Seite. Ein Akkordeon ist nicht automatisch eine – die Kennzeichnung `props.faq` (Schema v23, Kästchen „Als FAQ auszeichnen“) macht aus den Einträgen Frage-Antwort-Paare einer `FAQPage`: Titel als Frage, die Textbausteine des Eintrags ohne Auszeichnungen als Antwort; Einträge ohne Text fehlen. Nur was sichtbar auf der Seite steht, steht auch in den Daten – Google verlangt genau das, und eine erfundene Antwort im Markup wäre der schnellste Weg zu einer Abstrafung. Kein Datenbankwechsel; beide Einstellungen fallen auf leere Listen zurück, das Akkordeon ohne Kennzeichnung bleibt ein Akkordeon.
|
||||
## ADR 094 – Drei Symbole mehr, ein Menü-Knopf mit eigener Gestalt, ein Nach-oben-Knopf ohne Scroll-Animation (14.09.2026)
|
||||
|
||||
**Symbole.** Der Satz hatte vierzehn Zeichnungen und keine für Menschen, Musik und Haus – drei Bilder, die eine Pflege- oder Vereinsseite als erstes braucht. Sie kommen als `people`, `music` und `home` hinzu, gezeichnet im selben Strich, weiter als reine Pfadangabe in `blocks/icon.html.twig`. Kein Schemawechsel: `ICONS` ist eine Werteliste, kein Bausteintyp; ein Dokument mit einem neuen Namen ist für ältere Fassungen ungültig, was für Werte dieser Art im Projekt bereits gilt. Ein Test hält Liste und Zeichnungen zusammen, denn ein fehlender Pfad ergibt ein leeres `<path d="">`: unsichtbar, ohne Fehlermeldung.
|
||||
|
||||
**Die Farbe des Symbolbausteins.** Das eingebettete SVG trägt dieselbe Klasse `block-icon` wie seine Hülle und setzte damit `color:var(--accent)` erneut – eine am Block eingestellte Farbe erreichte die Zeichnung nie, und beide bisherigen Nachbauten haben sie mit eigenem CSS überschrieben. `.block-icon .block-icon{color:inherit}` löst es an der Wurzel und trifft ausschließlich das Paar aus Hülle und Zeichnung; Symbole in Karten, Kontaktangaben und Menüs liegen nicht ineinander und bleiben unberührt.
|
||||
|
||||
**Der Menü-Knopf.** Er ist kein eigener Baustein, sondern der Knopf im Menüblock, und war bisher unveränderlich: Striche plus „Menü“, Rahmen in der Textfarbe, 8 px Ecken. Seine Beschriftung wird zu einer Eigenschaft (`burgerLabel`: `both`, `text`, `icon`) – „Nur Symbol“ behält den Namen als `visually-hidden`, denn ein Knopf ohne Namen ist für Vorlesesoftware ein Knopf ohne Zweck. Seine Farben werden zu freien Blockstilen der neuen Gruppe `menu` mit dem Ziel `burger`, wie `img` beim Bild und `controls` beim Slider: der Compiler schreibt `#id .menu-burger>summary{…}`. So bleibt die Gestaltung dort, wo jede andere Gestaltung liegt, statt eine zweite Einstellungsart zu erfinden.
|
||||
|
||||
**Das aufgeklappte Menü im Kopfbereich.** Das `details` klappte inline auf, also innerhalb der schmalen Spalte neben der Marke, und schob den Kopf auseinander; auf dem Telefon sah es aus, als fehle das Menü. Im Kopfbereich – und nur dort, erkennbar an `.site-header` – liegt die geöffnete Fläche jetzt absolut unter dem Kopf über die volle Breite. Außerhalb des Kopfbereichs bleibt es beim Aufklappen im Fluss, weil ein Menü in einer Sektion keinen positionierten Anker hat, auf den es sich verlassen könnte.
|
||||
|
||||
**Der Nach-oben-Knopf.** Er blendete sich über `animation-timeline: scroll(root)` ein, abgesichert mit `@supports`. Ein Browser, der die Eigenschaft kennt, die Zeitachse aber nicht ausführt, besteht die Abfrage und lässt den Knopf im ersten Bild der Animation stehen – bei `animation-fill-mode: both` heißt das dauerhaft unsichtbar. Statt einer Fähigkeitsabfrage entscheidet jetzt eine Klasse: `scrolled.js` setzt `kant-top-armed` sofort und `kant-top-shown` ab der eingestellten Entfernung, das Stylesheet blendet zwischen beiden Zuständen über. Ohne Skript bleibt der Knopf sichtbar – erreichbar ist besser als unsichtbar –, und das Skript wird jetzt auch geladen, wenn nur der Knopf es braucht.
|
||||
|
||||
## ADR 093 – Soziale Profile: einmal gepflegt, überall gezeigt (Dokumentschema v23, 14.09.2026)
|
||||
|
||||
Ein Link zu Instagram im Fuß, derselbe im Kopf, ein dritter in der Kontaktsektion – bisher wären das drei von Hand gesetzte Symbol-Links, und eine geänderte Adresse hätte drei Stellen zu finden. Deshalb gehören die Profile zur Website, nicht zur Seite: `SiteSettings` führt unter `social` je Netzwerk eine HTTPS-Adresse (`SocialProfiles::NETWORKS`, feste Liste, weil jedes Netzwerk ein gezeichnetes Symbol braucht), gepflegt unter Einstellungen → Unternehmensdaten. Der Baustein `social` (v23) trägt nur die Darstellung: Größe aus `ICON_SIZES` als Klasse, Form (Symbol, runde oder eckige Fläche), Anordnung, Beschriftung an oder aus, optional eine Teilmenge der Netzwerke und `newTab`. Farbe, Abstand und Ausrichtung kommen aus den freien Blockstilen wie überall. Die Vorlage liest die Profile über das Twig-Global `siteSettings`, darum braucht die Rekursion in `block.html.twig` keine neue Durchreichvariable. Ohne eingetragene Profile gibt der Block auf der öffentlichen Seite nichts aus und zeigt im Editor einen Hinweis, wo sie einzutragen sind. Die Adressen stehen zugleich in `sameAs` der strukturierten Daten – dieselbe Aussage, ohne zweite Pflege. Symbole sind eigene Umrisszeichnungen im Strich des vorhandenen Symbolsatzes, keine Markenbilder und keine fremde Datei. Kein Datenbankwechsel: `social` ist ein Schlüssel im Einstellungs-JSON und fällt auf eine leere Liste zurück.
|
||||
|
||||
## 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.
|
||||
|
||||
Der gemeinsame StyleCompiler ergänzt bei Bildern mit eigener Ausrichtung horizontale Außenabstände am Rahmen und am enthaltenen Bild sowie `justify-self` für Raster. Verlinkte und vergrößerbare Bilder werden über dasselbe innere Bild erreicht. In horizontalen Flex-Gruppen bleiben die Außenabstände am Rahmen erhalten: Dort wirkt die vorhandene Einstellung auf der vertikalen Querachse und soll die horizontale Verteilung nicht verändern. Sektionen, Akkordeon-Einträge und Slider haben innere Inhaltswrapper, deren Blockfluss berücksichtigt wird.
|
||||
|
||||
Bild- und Elterneinstellungen werden je Gerät geerbt. Änderungen der Flexrichtung werden deshalb auch bei unverändertem Bildwert wirksam. Auto, Strecken und Grundlinie entfernen die zusätzliche horizontale Bildausrichtung und stellen die jeweiligen selbst gesetzten Außenabstände wieder her. Originalmaße, Bildquellen, Bildbreite, Beschriftungen und Links werden nicht umgeschrieben. Beliebiges benutzerdefiniertes CSS kann diese Layoutannahmen weiterhin überschreiben.
|
||||
|
||||
**Migration und Rückweg:** Keine Änderung am Dokument- oder Datenbankschema. Bestehende gespeicherte Ausrichtungswerte werden beim Rendern korrigiert, ohne erneutes Speichern. Vorschau und öffentliche Ausgabe verwenden denselben Compiler; kein zusätzliches öffentliches JavaScript. Ein Rückweg auf 1.8.3 erhält alle Daten, zeigt jedoch dessen bisherige Ausrichtung.
|
||||
|
||||
## ADR 085 – Akkordeon-Inhalte sind im Editor direkt zugänglich (14.09.2026)
|
||||
|
||||
@@ -70,6 +175,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.
|
||||
@@ -355,7 +462,7 @@ Das Stylesheet steht aus Geschwindigkeitsgründen inline und ist **über seinen
|
||||
|
||||
`Theme` ist die Grundlage darüber: eine frei definierbare Farbpalette, Schriften und die Grundtypografie. Farben werden als `@schlüssel` referenziert und zu CSS-Variablen; eine Farbe zu ändern wirkt überall, wo sie benutzt wird. Die Überschriftengrößen entstehen aus einer modularen Skala, sodass eine Zahl die ganze Hierarchie verschiebt.
|
||||
|
||||
**Google Fonts auf ausdrückliche Entscheidung von Umut.** Das bedeutet eine Anfrage an Google bei jedem Seitenaufruf, mit den bekannten Folgen für Datenschutz und Ladezeit – der Entwicklungsplan verlangt eigentlich lokale Schriften. Umgesetzt ist es so, dass nur die tatsächlich benötigten Schnitte geladen werden und die Richtlinie ihre Hosts nur dann öffnet, wenn wirklich eine Google-Schrift eingestellt ist. Der Weg zum Selbsthosten bleibt offen: es wäre ein zusätzlicher Quelltyp neben `system` und `google`.
|
||||
**Schriften von Google, aber nicht zur Laufzeit.** Eine Familie lässt sich weiterhin bei Google aussuchen; ausgeliefert wird sie aber ausschließlich vom eigenen Server. Die Dateien holt ein Knopf im Designbereich einmalig her, und die Ausgabe verlinkt nie ein fremdes Stylesheet – Näheres in ADR 096.
|
||||
|
||||
Die alten Tokenwerte aus v2 bis v9 bleiben gültig und werden weiterhin gerendert. Blöcke, die noch welche tragen, zeigen sie im Editor in einem eingeklappten Bereich „Klassische Werte dieser Seite“, damit ältere Seiten bearbeitbar bleiben, ohne das neue Panel zu überladen.
|
||||
|
||||
|
||||
+9
-1
@@ -1,5 +1,13 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Elektro Bernhard gemessen, 14.09.2026 (1.12.1)
|
||||
|
||||
Lighthouse **13** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der Referenzwebsite Elektro Bernhard (`var/elektro-bernhard.db`, Port 8090), vier Adressen: `/`, `/leistungen`, `/leistungen/notdienst`, `/kontakt`. Ergebnis nach den Korrekturen: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung überall 100 bis auf die mobile Startseite mit **99** (LCP 2,1 s durch das Hero-Foto als Hintergrund unter Mobildrosselung; CLS 0, TBT 30 ms). Berichte unter `reports/lighthouse-elektro-bernhard-2/`; der erste Lauf davor (`reports/lighthouse-elektro-bernhard/`) zeigte Barrierefreiheit 95–97.
|
||||
|
||||
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px) auf sechs Seiten: **null Verstöße**. Vorher gab es drei echte Befunde, alle behoben: Markennamen `#9aa0a6` auf Weiß (2,6:1 → `#6b7178`, 4,7:1), der rote Link „Kant-IT Solutions“ im dunklen Fuß (3,0:1 → hell), und auf der Leistungsübersicht sprang die Gliederung von H1 auf H3 (jetzt eine H2 vor den Karten). Ein vierter Befund lag im CMS: der Pause-Knopf des Sliders hatte den sichtbaren Text „Pause“, aber den zugänglichen Namen „Automatische Wiedergabe pausieren“ (WCAG 2.5.3); der Name beginnt jetzt mit dem sichtbaren Text. Achtung beim Messen: unmittelbar nach dem Laden meldet axe Kontrastverstöße an Elementen, die gerade eingeblendet werden (halb transparente Zwischenzustände); erst nach dem Durchscrollen und zwei Sekunden Wartezeit sind die Befunde belastbar.
|
||||
|
||||
Maschinenlesbar: `llms.txt`, `robots.txt`, `sitemap.xml` werden ausgeliefert, `LocalBusiness` mit drei `sameAs`-Profilen auf der Startseite, `BreadcrumbList` auf Unterseiten, `og:image` auf allen Inhaltsseiten. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, das tatsächliche Hosting.
|
||||
|
||||
## Ergänzung: Sichtbare leere Layouts, 14.09.2026 (1.6.2)
|
||||
|
||||
30 JavaScript-Tests und 27 PHP-Editor-/Sicherheitstests mit 312 Assertions bestanden. Vollständiger isolierter Chromium-Editortest einschließlich axe ohne Verstöße: direkte Tastatureinfügung in leere Sektionen/Spalten, genaues Drag-and-drop-Ziel und Scrollen am Rand, Spaltenreihenfolge auf Desktop und Mobil, Undo/Redo, Speichern/Neuladen, Veröffentlichung und Konflikte. Die Tests prüfen ausdrücklich, dass die neuen Editorhilfen weder im gespeicherten Blockbaum noch im öffentlichen HTML stehen. Desktop-, Mobil- und Drag-Screenshots visuell geprüft; Bericht unter `reports/editor/result.json`.
|
||||
@@ -247,7 +255,7 @@ Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der feste
|
||||
- **154 PHP-Tests / 563 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **15 JavaScript-Modelltests**.
|
||||
- Ein v10-Dokument mit Palettenfarbe, eigenem Farbwert, Abständen je Seite, Ecken je Ecke, Schatten, Buchstabenabstand, Hover-Zustand und eigenem Mobilwert wird Byte für Byte gespeichert und zu blockgenauem CSS: Regel für den Block, eigene Regel für `:hover,:focus-within` und eine Media Query für die schmale Breite.
|
||||
- **Vierzehn abgewiesene Eingaben schreiben nichts.** Darunter gezielt die Ausbruchsversuche: eine Farbe als Funktion (`rgb(1,2,3)`), eine Länge als Ausdruck (`calc(1px + 2px)`), eine angehängte Deklaration (`18px;position:fixed`), ein Schriftschlüssel mit Anführungszeichen und ein Hintergrundbild als Dateipfad. Jeder dieser Werte scheitert am verankerten Muster seines Typs, bevor er gespeichert wird.
|
||||
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ohne eingestellte Google-Schrift wird kein fremder Host erlaubt und keiner kontaktiert.
|
||||
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ein fremder Schrifthost wird nie erlaubt und nie kontaktiert: Schriften liefert seit 1.14.0 ausschliesslich der eigene Server (ADR 096).
|
||||
- **Acht ungültige Themes** werden abgewiesen, ohne das gespeicherte Theme zu verändern; danach liefert das Theme weiterhin die Voreinstellungen.
|
||||
- Im Editormodell geprüft: Vererbung, einzelnes Zurücksetzen, Entfernen leerer Reste, Kopieren ohne geteilte Objekte und vollständiges Undo.
|
||||
- **Im Browser geprüft wurde nur der Aufbau, nicht die Bedienung.** Im laufenden Editor wurde über das DOM bestätigt, dass das Designpanel aus dem Serverschema entsteht: sieben Gruppen, 58 Felder, 11 Farbwähler, 12 Schieberegler, 34 Einheitenauswahlen. **Es wurde kein Gestaltungsvorgang von Hand ausgeführt**, keine Tastaturbedienung erprobt und keine axe- oder Lighthouse-Messung gemacht. Für ein Werkzeug dieser Größe ist das ein erheblicher Vorbehalt.
|
||||
|
||||
+383
-1
@@ -1,5 +1,385 @@
|
||||
# Projektstatus
|
||||
|
||||
## 1.15.0 – Formulare, Statistik, Design und Seitenleiste überarbeitet (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fand die Formularanlage unschön und wollte Felder löschen können, die Statistik „lieblos“, auf der Designseite vordefinierte Farben, eigene Farben für Link- und Seitenfarbe und eine Vorschau der Schriften, und den Fuß der Seitenleiste (Fassung, Benutzer, Abmelden) besser. Nachgeschoben: eine Vorschau des Kontaktformulars, in der Rahmen-, Hintergrund- und Buttonfarbe direkt gewählt werden.
|
||||
|
||||
**Formulare (`forms.html.twig`, neu `form-editor.js`).** Jedes Feld ist eine Karte mit Nummer, Beschriftung, Typ, technischem Name, Hilfetext, Pflicht-Schalter und einem Entfernen-Knopf; „Feld hinzufügen“ klont eine `<template>`-Karte. Der technische Name folgt der Beschriftung (`Größe der Fläche` → `groesse_der_flaeche`), bis jemand ihn selbst schreibt; Optionen erscheinen nur bei einer Auswahl; eine Einwilligung ist sichtbar immer Pflicht. Ohne Skript bleibt alles bedienbar: eine Karte ohne Beschriftung wird nicht gespeichert. Neu ist der Abschnitt **Aussehen**: Buttonbeschriftung, Rahmen und Hintergrund der Felder, Button- und Buttonschriftfarbe, jeweils aus der Palette oder als eigener Wert (`FormRepository::COLORS`, geprüft mit `StyleSchema::COLOR`). Daneben eine **Vorschau**, die jeder Eingabe folgt und den Absendeknopf mit dem Standardbutton der Website zeichnet (`buttonSample`). Auf der Website schreibt `FormRepository::css()` je Formular eine Regel `.block-form[data-form="…"]{…}` in den gehashten Stylesblock (`PageController::renderPage`); Felder lesen `--form-border`/`--form-bg` (`site.css`), der Button bekommt `--kb-fill`/`--kb-ink` **und** die Darstellungsvariablen neu – am Wurzelelement wären sie schon mit den alten Werten aufgelöst. Das „Neues Formular“ ist ein zugeklapptes Karten-Details am Ende.
|
||||
|
||||
**Statistik (`statistics.html.twig`, `Statistics::overview()`).** Sechs Kennzahlkacheln: Aufrufe mit Vergleich zum gleich langen Zeitraum davor (`previousTotal`), Schnitt je Tag, stärkster Tag mit Datum und Wochentag (`busiestDay`), aufgerufene Seiten (`pageCount`), Aufrufe über Verweise (`referred`) und nicht gefundene Adressen (`missingTotal`). Der Verlauf hat Rasterlinien, jeder Balken einen `<title>` mit Tag und Zahl, der stärkste Tag ist hervorgehoben; dazu ein neues Wochentagsdiagramm im Schnitt (`weekdays`). Die Ranglisten tragen Rang und einen Anteilsbalken (SVG-Breite, kein Inline-Stil). Der Zeitraum ist eine Leiste mit gedrücktem Knopf in der Kopfzeile; „Was diese Zahlen nicht sagen“ ist zugeklappt.
|
||||
|
||||
**Design (`theme.html.twig`, neu `theme-panel.js`).** Die Palette sind Farbkarten, deren Fläche der Farbwähler selbst ist, mit Hexwert, Name, Schlüssel und Entfernen. Sieben fertige Paletten (`Theme::PALETTES`: Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht; die Linkfarbe erreicht auf dem Papierton mindestens 4,5:1) ersetzen die fünf Grundschlüssel und lassen eigene Farben stehen. Schriften sind Karten mit einer Probe in der Schrift selbst (lokale `fonts.css` wird geladen, wenn vorhanden – `FontLibrary::hasStylesheet()`), Schnitte als Chips, die Auswahlen für Fließtext/Überschriften/Button folgen neuen Schriften. Text-, Seiten- und Linkfarbe nutzen das Palettenbedienelement des Editors (`createColorControl`), das bei jeder Palettenänderung neu aufgebaut wird; daneben eine Typografie-Vorschau mit Überschriftenskala, Zeilenhöhe, Farben und Unterstreichung.
|
||||
|
||||
**Seitenleiste (`shell.html.twig`).** Unten eine Kontokarte: Avatar, Name, Rolle (Administrator/Veröffentlicher/Redakteur), rechts ein Abmeldesymbol mit Beschriftung für Vorlesesoftware; darunter „KantSites 1.14.2“ mit dem Update-Hinweis in einer Zeile. Neue Symbole `logout`, `plus`, `remove`. Auf Tablets wischt nur noch die Liste, Konto und Abmelden bleiben rechts im Bild; dafür braucht die Rasterspalte `minmax(0, 1fr)`, sonst schob die Leiste die Seite über den Rand.
|
||||
|
||||
Die Formularvorschau nennt den Kontrast der Buttonschrift auf der Buttonfarbe (WCAG-Formel, grün ab 4,5:1, gelb ab 3:1, sonst rot), damit eine unlesbare Wahl auffällt, bevor sie auf der Website steht. Die `pattern`-Attribute der Schlüsselfelder maskieren den Bindestrich (`[a-z][a-z0-9\-]*`): Chrome prüft `pattern` inzwischen mit dem `v`-Flag und lehnte das alte Muster mit einem Konsolenfehler ab – die Prüfung war damit wirkungslos.
|
||||
|
||||
**Geprüft:** `phpunit` mit PHP 8.4 508 von 508 grün, mit PHP 8.2 508 grün mit einer vorbestehenden Warnung in `UpdaterTest` (Git-Umgebung); neu getestet: Formularfarben auf der Seite samt CSP-Hash, Ablehnung ungültiger Farben, Statistiksummen und Vorzeitraum, drei Diagramme mit Titeln. `npm run test:editor` vollständig bestanden – darin die axe-Prüfung von Formularen, Statistik und Design. `test:buttons` besteht den Designseiten-Teil (Vorlage wählen, speichern, Werte erhalten) und scheitert danach wie `test:navigation-ux` an einem Klick auf den Strukturbaum des Editors (`#structure [data-block-id=…]` „not visible“) – eine Stelle außerhalb dieser Änderung, nicht weiter untersucht. `test:admin` scheitert vor den Formularen an zwei vorbestehenden Befunden: dem Wiedergabeknopf des öffentlichen Sliders bei reduzierter Bewegung und `nested-interactive` in den Menü-Zusammenfassungen (Ziehgriff im `summary`). In der Pilotkopie im Browser: Feld hinzufügen/entfernen, Namensableitung, Farbwahl mit Vorschau und Kontrasthinweis, Speichern, Regel und Buttonfarbe auf der öffentlichen Startseite; Palette „Ozean“ übernehmen, Schrift hinzufügen, Linkfarbe wählen; Seitenleiste und Bildschirme auf 1440, 900 und 375 px. Codestil mit php-cs-fixer (verbleibende Abweichung in `tests/CmsTest.php` ist vorbestehend). Veröffentlicht als 1.15.0, siehe Eintrag oben.
|
||||
|
||||
## 1.14.2 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.2` (Commit `0aa1ff6`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.2 – Fußzeile des Einwilligungshinweises: Einzelwahl links, Rechtslinks rechts.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.14.2 v1.14.2`), nicht aus der Arbeitskopie: dort lagen zu diesem Zeitpunkt nicht committete Änderungen einer anderen Sitzung (`site.css`, `archive.html.twig`, `page.html.twig`, `tests/CmsTest.php`, neu `src/Twig/HeaderExtension.php`), und ein erster Build aus der Arbeitskopie hatte sie mitgenommen – der wurde verworfen. Das Archiv enthält keine vorgefertigten `assets/build/`-Bündel mehr (die Arbeitskopie hatte welche, sie entstehen beim ersten Aufruf); daher 3546 statt 3552 Dateien.
|
||||
|
||||
**Anhänge:** `kantsites-1.14.2.zip` (4,7 MB, SHA-256 `236dff086267591a51198b55ddfbaf2ee999db8a081ec5bb1d28752260a3db3f`), `.sha256`, `install.php`; hochgeladen mit `var/publish-release-1.14.2.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** PHP 506 Tests grün (ein erster Lauf zeigte einen Fehler, während die andere Sitzung `tests/CmsTest.php` bearbeitete; zwei weitere Läufe grün); HTTP-Archiv-Update 1.14.1 → 1.14.2 `ok`; nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.14.2`, Installeradresse 200.
|
||||
|
||||
## 1.14.2 – Einwilligungshinweis: Fußzeile aufgeräumt (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fand „Einzeln wählen“ und die Rechtslinks unordentlich und schlug links/rechts vor.
|
||||
|
||||
**Umgesetzt (`consent-banner.html.twig`, `site.css`):** Beide stehen in einer Zeile `.consent-foot` – Einzelwahl links, Datenschutz · Impressum rechts, auf gemeinsamer Grundlinie. Aufgeklappt nimmt die Einzelwahl die volle Breite (`[open]{flex-basis:100%}`), die Links rutschen rechtsbündig darunter. Die Links stehen ohne Punkt dazwischen mit 14 px Abstand; ohne beide Adressen entfällt die Zeile. Ein Stolperstein: `.consent-bar p` schlug `.consent-links` und nahm den rechten Rand weg – der Selektor heißt jetzt `.consent-bar p.consent-links`.
|
||||
|
||||
**Geprüft:** vier Gestaltungsvarianten auf 1440 und 390 px, zu- und aufgeklappt (Screenshots `reports/consent-look/*-offen.png`), sechs Einwilligungstests grün. Veröffentlicht als 1.14.2, siehe Eintrag oben.
|
||||
|
||||
## 1.14.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.1` (Commit `0bdcc7f`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.1. Enthält den gestaltbaren Einwilligungshinweis, den bei offener Frage erreichbaren Nach-oben-Knopf, den korrigierten Abstand unten und die orangen Beschriftungen im Editor; dazu `860bed9` (Seed-Skript Tagespflege Moldovan, nicht im Archiv).
|
||||
|
||||
**Anhänge:** `kantsites-1.14.1.zip` (4,7 MB, 3552 Dateien, SHA-256 `c0620a3155db894373199dbe2e286744dd7b21eed2c355e8d2a890652d1d203e`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.14.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** PHP 505 Tests grün; `browser-check.mjs`, `editor-check.mjs` bestanden; HTTP-Archiv-Update 1.14.0 → 1.14.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200). Nach der Veröffentlichung ohne Anmeldung: alle drei Anhänge bytegleich, `/releases/latest` nennt `v1.14.1`, Installeradresse antwortet 200.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv, Migrationen abschließen (keine neuen), dann unter Einstellungen → Einwilligung das Aussehen wählen; `var/kantit.db` mit dem korrigierten Kopfmenü hochladen.
|
||||
|
||||
## 1.14.1 – Einwilligungshinweis gestaltbar, Nach-oben-Knopf bei offener Frage, orange Beschriftungen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut wollte das Banner gestaltbar (rund, Farben) und schöner, den Abstand unten korrigiert, belegte Eigenschaften im Editor orange statt nur fett mit Punkt, und Gerätechips, die sich „aktiv überlagern“.
|
||||
|
||||
**Einwilligungshinweis (ADR 097).** Sieben neue Einstellungen: `consentLayout` (karte/leiste/ecke), `consentShape` (weich/rund/eckig), `consentTitle`, `consentColor`, `consentInk`, `consentAccent`, `consentAccentInk`; Farben mit `StyleSchema::COLOR` geprüft, Palette über `Theme::cssColor()` (neu, ersetzt die Kopie im Nach-oben-Knopf). `Consent::styles()` schreibt `:root{--kant-consent-…}` in den gehashten Stylesblock; die Anordnung ist eine Klasse. Neues Grundlayout: Streifen und Symbol in der Knopffarbe, gleich breite Knöpfe, aufklappbares „Einzeln wählen“ mit Pfeil, unterstrichene Rechtslinks; Leiste auf breiten Bildschirmen zweispaltig. Archiv und `/einwilligung` bekommen Theme- und Hinweisvariablen als gehashten Stylesblock – das Archiv trug bisher gar keine Palette.
|
||||
|
||||
**Abstand unten:** 320 px bis 620 px (Leiste bis 299 px hoch), 270 px bis 899 px, 200 px darüber; die Fläche scrollt ab `100dvh − 24px` in sich. Nach-oben-Knopf hebt sich bei Leiste und Ecke auf jeder Breite (`:has()`), bei der Karte bis 799 px.
|
||||
|
||||
**Editor:** belegte Beschriftungen in `--used` (#9d4e0d, 5,6:1); Chips mit 6 px Lücke, Fokusring ohne Versatz, Gruppe `flex:none`. Ein Überlagern der Chips ließ sich in 1.14.0 nicht reproduzieren – am Handy (390 px) liegen die drei Chips bei 22 px Breite mit 6 px Abstand, kein `outline` gezeichnet; die Rückmeldung bezog sich wohl auf den `outline`-Ring von 1.13.0.
|
||||
|
||||
**Geprüft:** PHP 505 Tests grün, darunter neu `testTheConsentQuestionTakesLayoutShapeAndColoursFromTheSettings` (Standard, eigene Werte, unbekannter Palettenschlüssel fällt weg, Einwilligungsseite mit Hash, ungültige Farbe abgewiesen). Chromium: vier Varianten (Karte, Leiste, Ecke rund dunkel, Karte eckig dunkel) auf 1440 und 390 px – Klasse, Radien, Farben und Überschrift stimmen, Nach-oben-Knopf sichtbar, treffbar, ohne Überlappung; `/einwilligung` 200 mit gehashtem `style-src`. Nach-oben-Prüfung auf acht Breiten bestanden. `browser-check.mjs`, `editor-check.mjs` (Einstellungen, Einwilligung) und axe über die Editorseitenleiste bestanden. Screenshots: `reports/consent-look/`, `reports/admin-hints/`.
|
||||
|
||||
**Nicht geprüft:** echtes iOS-Safari; der `@supports not`-Zweig; Lighthouse.
|
||||
|
||||
## Nach-oben-Knopf bei offener Einwilligungsfrage (15.09.2026)
|
||||
|
||||
**Anlass:** Umut meldete, der Nach-oben-Knopf funktioniere auf dem Handy nicht.
|
||||
|
||||
**Befund.** Der Knopf selbst funktioniert auf dem Telefon: In iPhone- und Pixel-Emulation (Chromium), lokal wie auf `web.kant-it.de` (dort bereits 1.14.0 mit `scrolled.js`), erscheint er nach 500 px, ist treffbar, und Tippen springt nach oben – **sobald die Einwilligungsfrage beantwortet ist**. Vorher galt `body.consent-open .scroll-top{display:none}` auf allen Geräten. Live trägt jeder Erstbesuch `consent-open`, und auf dem Telefon bleibt die Leiste unten oft einfach stehen – der Knopf kam dort nie. Kein Fehler im Skript und nicht der alte Safari-Fehler aus 1.13.0.
|
||||
|
||||
**Korrektur (`site.css`, `page.html.twig`).** Der Knopf bleibt bei offener Frage sichtbar. Gemessen deckt die Leiste die rechte Ecke nur bis knapp 800 px ab (bis 620 px volle Breite, 262–285 px hoch; darüber zentriert mit höchstens 620 px). In dieser Stufe setzt sich der Knopf per Ankerpositionierung 12 px auf die Oberkante der Leiste (`anchor-name:--kant-consent`, `bottom:calc(anchor(top) + 12px)`), unabhängig davon, wie hoch ihr Text umbricht. Browser ohne Ankerpositionierung blenden ihn dort weiter aus (`@supports not`). Ab 800 px bleibt er an seinem Platz. Weil ein Anker im Dokument vor dem positionierten Element stehen muss, gibt die Vorlage die Leiste jetzt vor dem Knopf aus.
|
||||
|
||||
**Geprüft:** Chromium, isolierte Kopie der Pilotdatenbank, Einwilligung offen auf 360, 390, 700, 768, 799, 800 und 1440 px sowie beantwortet auf 390 px – in allen acht Fällen sichtbar, keine Überlappung mit der Leiste, treffbar, nach dem Tippen `scrollY` 0. Twig-Lint gültig. PHP-Tests 504 grün, `browser-check.mjs` und `editor-check.mjs` (einschließlich Einwilligungsabläufe) bestanden.
|
||||
|
||||
**Nicht geprüft:** echtes iOS-Safari – WebKit ist für Playwright auf diesem Rechner nicht installiert. Der `@supports not`-Zweig wurde nicht ausgeführt, weil Chromium Ankerpositionierung kann.
|
||||
|
||||
**Nebenbefund, nicht geändert:** Bis 620 px reserviert `body.consent-open` 260 px Abstand unten, die Leiste ist mit Standardtext bis 285 px hoch – auf kleinen Telefonen liegen die letzten rund 25 px der Seite unter der offenen Leiste.
|
||||
|
||||
**Nächster Schritt:** nicht committet und nicht veröffentlicht; live erst mit einem Release (z. B. 1.14.1).
|
||||
|
||||
## 1.14.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.0` (Commit `a434e0c`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.0. Enthält die Schriften vom eigenen Server, den Hinweisstreifen, System und Update auf dem Telefon, die Gerätechips sowie Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten.
|
||||
|
||||
**Anhänge:** `kantsites-1.14.0.zip` (4,7 MB, SHA-256 `8360566b1eaed670b29f506262953d3b17f95893e23c7f4a940ff6d5e3f62735`), `.sha256`, `install.php` mit eingetragener Prüfsumme. Hochgeladen mit `var/publish-release-1.14.0.py` über den Git-Zugang dieses Rechners, erst als Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** 504 PHP-Tests grün; `browser-check.mjs` und `editor-check.mjs` bestanden; HTTP-Archiv-Update 1.12.1 → 1.14.0 `ok`. Nach der Veröffentlichung ohne Anmeldung: alle drei Anhänge bytegleich mit den lokalen Dateien, `/releases/latest` nennt `v1.14.0`, die Installeradresse `…/releases/download/v1.14.0/kantsites-1.14.0.zip` antwortet mit 200.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv, danach Migrationen abschließen und `var/kantit.db` mit dem korrigierten Kopfmenü hochladen.
|
||||
|
||||
## Globale Hinweise, der Schriftenknopf und die Gerätechips (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fehlte ein Hinweis, der auch außerhalb der Systemseite sagt, dass etwas zu tun ist; der Knopf „Schriften holen“ sah nicht aus wie die übrigen; und im Designbereich störte ein roter Rahmen an den Gerätechips, der abgeschnitten wirkte.
|
||||
|
||||
**Hinweisstreifen auf jedem Verwaltungsbildschirm (`kant_admin_notices()`, `admin/shell.html.twig`).** Über dem Inhalt steht jetzt, was einen Knopf braucht, und der Knopf gleich daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren* (System), „Schriftdateien fehlen: …“ → *Schriften holen* (Design). Keine Zeile davon geht ins Netz – die verfügbare Fassung steht im Protokoll der täglichen Aufgabe `update-check`, die fehlenden Schriften sind ein Blick in den Ordner. Der Updatehinweis erscheint nur für `ROLE_ADMIN`, weil nur die auf die Systemseite dürfen. Warm statt rot: es ist eine Aufgabe, kein Fehler. Auf 390 px bricht der Streifen um, der Knopf rutscht unter den Text.
|
||||
|
||||
**Schriftenknopf.** Er trug gar keine Klasse und war deshalb der einzige Knopf der Verwaltung im Standardaussehen des Browsers. Jetzt `primary` (bzw. `secondary` beim erneuten Holen), und der ganze Abschnitt steht wie alles andere in einer Karte mit Kopf und Körper.
|
||||
|
||||
**Gerätechips D/T/M (`editor.css`).** Der rote Kasten war `outline:2px` mit `outline-offset:1px` auf dem gerade bearbeiteten Gerät. Ein `outline` liegt außerhalb des Kästchens: bei 2 px Abstand lief er unter den Nachbarchip und wurde von der seitlich beschnittenen Sidebar gekappt – daher der abgeschnittene Eindruck. Jetzt zwei Zustände mit zwei Mitteln:
|
||||
|
||||
- **belegt** (dieses Gerät trägt einen eigenen Wert): gefülltes Orange `--used: #9d4e0d`, weiße Schrift – 5,6:1 Kontrast. Dieselbe Farbe trägt der Punkt vor der Beschriftung, damit „hier steht ein eigener Wert“ überall gleich aussieht.
|
||||
- **gerade bearbeitet**: 2 px Rahmen **innen** (`border-width`, kein `outline`), auf belegten Chips zusätzlich ein weißer Innenring. Nichts ragt mehr aus dem Kästchen, nichts wird beschnitten.
|
||||
|
||||
Chips von 19 auf 22 px, Abstand von 2 auf 4 px – auf einen Blick lesbar, welche Geräte an einer Eigenschaft hängen.
|
||||
|
||||
**Geprüft** (isolierte Kopie der Pilotdatenbank, künstlich auf „Lato 300 fehlt“ und „Fassung 9.9.9 verfügbar“ gesetzt): beide Streifen erscheinen auf `/admin`, tragen die richtigen Ziele, und auf 390 px bleibt die Dokumentbreite bei 390. Schriftenknopf trägt `primary`. Chips: belegte auf `rgb(157,78,13)`, bearbeitete mit 2 px Rahmen, kein gezeichnetes `outline` mehr. axe über die Editorseitenleiste ohne Verstöße. PHP-Tests: 498, ein bekannter Fehler (`testAnyPublishedPageCanBecomeTheRootAndEverythingFollows`). Screenshots: `reports/admin-hints/`.
|
||||
|
||||
**Zu beachten:** `admin.css` und `editor.css` hängen an `?v={{ kant_version() }}`. Bis die `VERSION` steigt, liefert der Browser die alten Fassungen aus dem Zwischenspeicher.
|
||||
|
||||
## Schriften nur noch vom eigenen Server, kein sichtbarer Schriftwechsel mehr (14.09.2026)
|
||||
|
||||
**Anlass:** Umut sah im Browser, wie die Schrift kurz von fett auf dünn sprang, und stellte zwei Regeln auf: keine Schriften über Adressen von Google, und kein Nachladen nach dem fertigen Seitenaufbau.
|
||||
|
||||
**Zwei getrennte Ursachen, beide belegt.**
|
||||
|
||||
1. **Der späte Schnitt.** `FontLibrary::preload()` holte je Familie nur die **ersten zwei** Schnitte vorab. Das Kant-IT-Theme nennt Lato 400, 700 und 900; die Überschriften stehen auf 800/900 und landen damit auf Lato 900 – dem einzigen Schnitt ohne Vorabholen. Gemessen am Nachbau: `lato-400` und `lato-700` starteten bei 151 ms, `lato-900` erst bei 156 ms, also nach dem Stylesheet. Mit `font-display:swap` zeichnet der Browser die Überschrift bis dahin in der Ersatzschrift und tauscht sie danach – der gemeldete Sprung. Jetzt wird jeder Schnitt des Themes vorab geholt (lateinischer Grundbereich; die erweiterten Bereiche bleiben draußen).
|
||||
2. **Google in der Editorvorschau.** `EditorController` und `PartialController` gaben der Vorlage `googleFonts` und **kein** `localFonts`. In der Vorschau kamen die Schriften damit bei jedem Aufruf von `fonts.googleapis.com` und `fonts.gstatic.com` – zwei fremde Verbindungen hintereinander, und genau dort arbeitet Umut am meisten. Beide Vorschauen nutzen jetzt dieselben lokalen Dateien wie die öffentliche Seite.
|
||||
|
||||
**Die Regel steht jetzt im Code, nicht in der Einstellung.** `page.html.twig` hat keinen Google-Zweig mehr; fehlen die Schriftdateien, zeigt die Website die Ersatzschrift des Themes. Die Kopfzeilen lassen keinen fremden Schrifthost mehr zu – öffentlich `font-src 'self'`, in der Verwaltung `style-src 'self'; font-src 'self'` statt der bisherigen Ausnahmen für `fonts.googleapis.com`/`fonts.gstatic.com`. `Theme::googleFontsUrl()` ist entfernt; das Herunterladen der Dateien über die Theme-Seite bleibt unverändert und baut seine Adresse selbst. Die Vorabholen-Verweise stehen jetzt **vor** dem Stylesheet, und die Texte auf der Theme-Seite sagen nicht mehr „kommen zurzeit von Google“, sondern „die Dateien fehlen, bis dahin die Ersatzschrift“.
|
||||
|
||||
**Geprüft** (Chromium, kalter Zwischenspeicher, isolierte Kopie der Pilotdatenbank): alle drei Schriftdateien starten bei 96 ms und sind bei 166–168 ms fertig, der erste sichtbare Inhalt erscheint bei 292 ms – die Schriften stehen also vor dem ersten Zeichnen, es gibt nichts mehr zu tauschen. Jede Datei wird genau einmal angefragt. Über die ganze Sitzung – öffentliche Seite, Anmeldung, Seiteneditor mit Vorschau, Bereiche – **keine einzige Anfrage außerhalb des eigenen Servers**. PHP-Tests: 498, ein bekannter Fehler (siehe unten). `npm run test:browser` und der Lighthouse-Lauf sind **nicht** gelaufen.
|
||||
|
||||
**Tests angepasst:** `testFontsAreReadFromGoogleStylesheetAndServedFromHere` prüft jetzt die Ersatzschrift statt des Google-Verweises und alle drei Vorabhol-Dateien; `testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy` heißt `…AndNoFontIsEverFetchedFromGoogle` und prüft die engere Kopfzeile. In `testEveryAllowedIconHasADrawingAndTakesItsColourFromTheBlock` stand eine echte Zeilenschaltung im Suchmuster; unter Windows mit `core.autocrlf=true` schlägt das fehl, sobald die Arbeitskopie CRLF trägt. Das Muster nimmt jetzt `\R` und gilt für beide Schreibweisen; die Vorlage selbst bleibt unverändert.
|
||||
|
||||
**Weiterhin offen, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl – der Test erwartet `<a href="/" aria-current="page">Start</a>`, das Menü schreibt seit den Symbolen `<span class="nav-label">` um die Beschriftung.
|
||||
|
||||
## Kopfmenü im Hochformat und die Update-Ansicht auf dem Handy (14.09.2026)
|
||||
|
||||
**Anlass:** Umut meldete, das Mobilmenü im Kopf erscheine nur im Querformat, und bat darum, Texte und Ansichten der Update-Seite fürs Handy zu kürzen.
|
||||
|
||||
**Befund zum Kopfmenü – falsch gebaut, kein Fehler im CMS.** Der Kopfbereich trug zwei Menüblöcke: `area-header-menu` (waagerecht, `mobile: display:none`) und daneben `area-header-burger` (eigener Burger-Block, nur mobil sichtbar). Der veröffentlichte Stand auf `web.kant-it.de` kennt den zweiten Block nicht – abgerufen am 14.09.2026 enthält das Markup dort nur `area-header-menu` samt `@media (max-width:767px){#area-header-menu{display:none}}`. Damit ist im Hochformat (bis 767 px) gar kein Menü da, während im Querformat (768–1024 px) das Standardverhalten `collapse: tablet` denselben Block zum Burger zusammenlegt und ein Knopf erscheint. Genau die gemeldete Beobachtung. In `var/kantit.db` lag zwar ein zweiter Burger-Block, der das Loch stopfte, aber mit eigener Gestaltung: im Hochformat ein 161 px breiter Knopf, im Querformat der 101 px breite des waagerechten Menüs.
|
||||
|
||||
**Behoben in `var/kantit.db`:** ein einziger Menüblock `area-header-menu` mit `collapse: tablet` und `burgerLabel: both`, ohne `display:none` auf Mobil, `burgerFontSize: 14px`; der doppelte Block `area-header-burger` ist entfernt (Kopfbereich Fassung 8, Sicherung `var/kantit.db.vor-kopfmenue-20260914-2244.bak`). Geprüft am laufenden Nachbau: 375 px Hochformat und 844 px Querformat zeigen denselben Knopf („MENÜ“, 101 × 45 px, 14 px), die Liste bleibt in beiden verborgen und die aufklappende Fläche liegt über die volle Breite unter dem Kopf; ab 1025 px stehen die vier Einträge wieder waagerecht. Startkonfiguration `kantit` (Port 8130) in `.claude/launch.json` ergänzt.
|
||||
|
||||
**Update-Ansicht (`templates/admin/system.html.twig`, `public/assets/admin.css`).** Die Seite schob sich auf dem Handy seitlich aus dem Bild: `.stack` ist ein Raster mit automatischer Spalte, und eine automatische Rasterspalte wächst auf die Mindestbreite ihres breitesten Inhalts – eine lange Datenbankpfadangabe reichte. `.stack` bekommt `minmax(0, 1fr)`, `.facts` beide Spalten als `minmax(0, …)` und unter 620 px nur noch eine, `.card-body` und die Prüfpunkte `min-width: 0` samt `overflow-wrap: anywhere`. Die Sicherungstabelle wird unter 620 px zu gestapelten Blöcken (`.table-stack`, Spaltenüberschrift als `data-label`), statt waagerecht zu scrollen. Texte gekürzt und das Kleingedruckte in zugeklappte `<details class="hint">` gelegt, ebenso die älteren 15 der 20 Protokollzeilen.
|
||||
|
||||
**Geprüft:** 390 × 844 mit Touch-Emulation gegen eine isolierte Kopie der Pilotdatenbank (`tests/pilot-fixture.php`, eigenes Zufallskonto): Dokumentbreite 390 statt 492, kein Element mehr breiter als der Inhaltsbereich, Wortzahl der Seite 1017 → 556, axe ohne Verstöße. `/admin`, `/admin/media`, `/admin/menus`, `/admin/forms`, `/admin/theme`, `/admin/settings` bleiben mobil bei 390 px; dieselben Seiten und `/admin/system` bei 1440 px unverändert breit. Screenshots: `reports/system-mobile/`.
|
||||
|
||||
**Bekannter Fehler, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl (498 Tests, 1 Fehler) – der Test erwartet `<a href="/" aria-current="page">Start</a>`, das Menü schreibt seit den Symbolen `<span class="nav-label">` um die Beschriftung. Auch ohne diese Änderungen und mit beiden PHP-Fassungen reproduzierbar.
|
||||
|
||||
**Nächster Schritt:** Umut spielt `var/kantit.db` auf den Server; erst dort ist das Kopfmenü im Hochformat live. Für die Admin-Änderungen die `VERSION` beim nächsten Release erhöhen, sonst liefert der Browser `admin.css?v=1.13.0` weiter aus dem Zwischenspeicher.
|
||||
|
||||
## 1.14.0 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten; Elektro Bernhard aktualisiert (14.09.2026)
|
||||
|
||||
**Anlass:** Umut nannte die tatsächlichen Bürozeiten (Mo–Fr 7:30–17:00), das Einsatzgebiet von Karlsruhe bis Offenburg und wollte die FAQ „mit allem, was notwendig ist“ – das JSON-LD, das das CMS bisher nicht hergab (siehe Lücke in den Einträgen vom Abgleich).
|
||||
|
||||
**Umgesetzt (ADR 095):** Einstellungen `openingHours` und `areaServed` mit Formular unter Unternehmensdaten; `SiteSettings::organisation()` gibt `openingHoursSpecification` (gleiche Zeiten zusammengefasst) und `areaServed` aus. Akkordeon-Eigenschaft `faq` (Schema v23, Kästchen im Editor); `PageController` baut daraus eine `FAQPage` mit Frage und Antwort aus dem sichtbaren Inhalt. Fünf neue Tests: Zusammenfassung und Reihenfolge der Zeiten, Abweisung unvollständiger oder verdrehter Zeiten, FAQPage nur aus gekennzeichneten Akkordeons und nur mit Antwort, Abweisung der Kennzeichnung vor v23, und – nach einem Fund der vollen Suite – dass ein Bildschirm, der die geladenen Einstellungen unverändert zurückgibt (Anlegen der Rechtsseiten, Installer), Öffnungszeiten und Einsatzgebiet nicht löscht.
|
||||
|
||||
**Elektro Bernhard:** Bürozeiten überall Mo–Fr 07:30–17:00 Uhr (Fuß, Kontakt, Beschreibung, FAQ); Einsatzgebiet Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach, Murgtal, Ettlingen, Karlsruhe, Achern, Oberkirch, Offenburg in Text und `areaServed`; FAQ um „Wann ist das Büro erreichbar?“ und „Wie läuft eine Anfrage ab?“ ergänzt und als FAQ gekennzeichnet. Die Öffnungszeiten weichen bewusst von der laufenden Website ab (dort Mo–Do 7:00–16:30, Fr bis 13:00) – Umuts Angabe gilt.
|
||||
|
||||
**Arbeitsweise:** Weil eine parallele Sitzung im Hauptarbeitsbaum dreizehn Dateien offen hatte, darunter Validator, PageController und Editor, entstand diese Änderung im eigenen Worktree `var/wt-faq` auf dem Zweig `oeffnungszeiten-faq` und wurde von dort auf `main` gebracht. Der Hauptarbeitsbaum muss danach `git pull` ausführen.
|
||||
|
||||
## 1.13.0 – Drei Symbole mehr, gestaltbarer Menü-Knopf, Nachbau „Tagespflege Moldovan“ (14.09.2026)
|
||||
|
||||
**Anlass:** Umut ließ die ZIP-Vorlage „Tagespflege Moldovan“ nachbauen. Beim Nachbau fehlten dem Symbolsatz genau die drei Bilder, die die Vorlage zeigt; danach kamen zwei Meldungen vom Telefon: das Menü sei nicht zu sehen, und „Scroll to top“ funktioniere nicht.
|
||||
|
||||
**Nachbau (`scripts/seed-tagespflege-moldovan.php`, `scripts/reset-tagespflege-moldovan.sh`).** Der Onepager aus dem Paket vom 09.09.2026, gebaut aus gewöhnlichen Bausteinen: Kopfleiste mit Telefonzeile, klebender weißer Kopf mit Wortmarke, Hero mit der limettenen Schnittfläche hinter dem Foto (`clip-path` als eigenes CSS auf einer Fläche), Notizkarte und Bildunterschrift, Werteband, Willkommen mit Porträt-Signatur, drei Angebotskarten, Zeitleiste mit Verbindungslinie aus Flächen, dunkler Kontaktbereich mit schräger Fläche, Formularkarte und Fuß mit limettenem Abschluss. Farben, Schriftgrößen, Abstände und Umbrüche sind aus `style.css` übernommen, ebenso die drei Gerätestufen. Eigene Installation: `var/tagespflege-moldovan.db`, `var/tagespflege-moldovan-media`, Bildquellen in `var/tagespflege-moldovan-assets`, Startkonfiguration auf Port 8089. Zwei bewusste Abweichungen: das Formular verschickt serverseitig statt eine Mailto-Nachricht vorzubereiten, und das Markenzeichen liegt als PNG vor, weil die Medienverwaltung JPEG, PNG und WebP nimmt. Impressum und Datenschutz fehlen wie in der Vorlage – für einen Livegang nachzutragen.
|
||||
|
||||
**Symbole (ADR 094).** `people`, `music` und `home` ergänzen den Satz in `DocumentValidator::ICONS`, `blocks/icon.html.twig`, `editor.js` und `menu-editor.js`. Ein Test hält Liste und Zeichnungen zusammen. `.block-icon .block-icon{color:inherit}` lässt die am Block eingestellte Farbe endlich an der Zeichnung ankommen – das eingebettete SVG trug dieselbe Klasse wie seine Hülle und setzte die Akzentfarbe erneut.
|
||||
|
||||
**Menü-Knopf (ADR 094).** Neue Eigenschaft `burgerLabel` (`both`, `text`, `icon`; „Nur Symbol“ behält den Namen als `visually-hidden`) und die neue Stilgruppe `menu` mit dem Ziel `burger`: `burgerColor`, `burgerBackgroundColor`, `burgerBorderColor`, `burgerBorderWidth`, `burgerBorderRadius`, `burgerFontSize`. Der Compiler schreibt `#id .menu-burger>summary{…}`. Im Kopfbereich klappt das Menü als Fläche über die volle Breite unter den Kopf auf statt inline in der schmalen Spalte neben der Marke – das war die gemeldete „unsichtbare“ Navigation.
|
||||
|
||||
**Nach-oben-Knopf (ADR 094).** Die Einblendung hängt nicht mehr an `animation-timeline: scroll()`, sondern an den Klassen `kant-top-armed` und `kant-top-shown`, die `scrolled.js` setzt; das Skript wird jetzt auch geladen, wenn nur dieser Knopf es braucht. Ohne Skript bleibt der Knopf sichtbar.
|
||||
|
||||
**Geprüft:** PHP-Suite 498 Tests (fünf neue: Symbolliste gegen Zeichnungen, drei Knopfvarianten, abgewiesene Variante, Knopffarben am `summary`); einziger Fehlschlag ist der bekannte Altfehler `testAnyPublishedPageCanBecomeTheRoot…`. 33 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung sauber, Editor-Browserprüfung vollständig erfolgreich einschließlich axe. Playwright auf dem Nachbau bei 1440, 900 und 390 px sowie als iPhone 13: Seite gegen die Vorlage verglichen, Menü öffnet als Fläche unter dem Kopf, Sprungziele, 404-Seite, keine Konsolenfehler. Nach-oben-Knopf auf der Elektro-Bernhard-Installation als iPhone geprüft: oben verborgen, ab der Schwelle sichtbar, springt an den Anfang.
|
||||
|
||||
**Veröffentlicht:** Commit `73719ba` und Tag `v1.13.0` auf `origin`; [Release 1.13.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.13.0) mit ZIP (3546 Dateien, 4,6 MB), SHA-256-Datei und `install.php`. Das Archiv wurde aus einem sauberen Worktree des Tags gebaut (`var/release-source-1.13.0`), `vendor/` ohne Entwicklungspakete; die einzige `.env` im Archiv ist die Vorlage ohne Geheimnis. Die drei Anhänge wurden anonym von der Releaseadresse geladen und bytegenau mit den gebauten Dateien verglichen; SHA-256 `14e333e0…59dcbf`, identisch mit der Prüfsumme, die `install.php` mitbringt.
|
||||
|
||||
## 1.12.1 – Elektro Bernhard gemessen: Lighthouse und axe (14.09.2026)
|
||||
|
||||
**Anlass:** Umut fragte nach Google-Bewertung, SEO, llms.txt und Barrierefreiheit der neuen Website.
|
||||
|
||||
**Ergebnis:** Lighthouse-Mediane auf vier Seiten, Mobil und Desktop: alles 100, nur die mobile Startseite hat Leistung 99 (LCP 2,1 s durch das Hero-Foto). axe auf sechs Seiten in zwei Breiten: null Verstöße. Details und Messbedingungen in `docs/QUALITY.md`.
|
||||
|
||||
**Behoben:** drei Kontrast-/Gliederungsbefunde in der Website (Skript) und einer im CMS: der Pause-Knopf des Sliders verletzte WCAG 2.5.3 (sichtbarer Text „Pause“, Name „Automatische Wiedergabe pausieren“); `slider.js` bildet den Namen jetzt aus dem sichtbaren Text, `scripts/admin-quality-check.mjs` folgt.
|
||||
|
||||
**Veröffentlicht:** Commit `042aef9` und Tag `v1.12.1` auf `origin`; [Release 1.12.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.12.1) mit ZIP (3546 Dateien, 4,6 MB), SHA-256-Datei und `install.php`, gebaut aus dem sauberen Worktree `var/release-source-1.12.1`; alle drei Anhänge anonym geladen und bytegenau verglichen, SHA-256 `c7a2de81…8f1a23`.
|
||||
|
||||
## 1.12.0 – Soziale Profile als Baustein; Elektro Bernhard neu gestaltet (14.09.2026)
|
||||
|
||||
**Anlass:** Umut fand den ersten Nachbau „grauenhaft“, fragte nach dem transparenten Menü und vermisste Social-Icons im CMS – „gescheit“, also einmal zentral gepflegt und überall mit eigener Größe und Farbe einsetzbar.
|
||||
|
||||
**Soziale Profile (Dokumentschema v23, ADR 093).** Unter Einstellungen → Unternehmensdaten gibt es je Netzwerk ein Adressfeld (`SocialProfiles::NETWORKS`: Facebook, Instagram, LinkedIn, YouTube, TikTok, X, Threads, Pinterest, Xing, WhatsApp, Google-Unternehmensprofil; nur HTTPS). Der neue Baustein `social` (Werkzeugkasten → Bausteine → „Soziale Profile“) zeigt sie und bestimmt Größe (die 15 Symbolstufen), Form (nur Symbol, runde oder eckige Fläche), Anordnung, Beschriftung, neuen Tab und optional eine Teilmenge der Netzwerke; Farbe, Hover-Farbe, Abstand und Ausrichtung kommen aus den freien Blockstilen. Die Vorlage liest die Profile über das Twig-Global `siteSettings`, ohne Profile gibt sie nichts aus und zeigt im Editor einen Hinweis. Die Adressen stehen automatisch in `sameAs` der strukturierten Daten. Symbole sind eigene Umrisszeichnungen im Strich des Symbolsatzes. Neue Dateien `src/Content/SocialProfiles.php`, `templates/blocks/social-icon.html.twig`; Änderungen an Validator, Einstellungen, Controller, Vorlagen, `site.css`, Editor (Modell, Eigenschaften, Bibliothekssymbol).
|
||||
|
||||
**Zur Frage nach dem transparenten Menü:** Nein, im ersten Nachbau war der Kopf weiß. Jetzt nutzt die Website den Mechanismus aus 1.11.1: `headerOverlay` und `stickyHeader` an, Kopfsektion mit `#ffffff00` als Hintergrund und Weiß mit Schatten `scrolled`. Das Logo (schwarze Schrift, transparenter Grund) sitzt auf einer weißen Fläche, damit es über dem dunklen Foto lesbar bleibt.
|
||||
|
||||
**Elektro Bernhard neu gestaltet** (`scripts/seed-elektro-bernhard.php`, vollständig neu geschrieben): dunkler Hero mit dem Teamfoto als Hintergrund und Überlagerung, Lato aus dem lokalen Schriftordner, Versal-Kleinzeilen in Rot, Leistungskarten mit Symbolfläche, Schatten und Hover-Lift, Foto mit starkem Schatten neben Text und vier Stärken, Kundenstimmen als Slider mit sechs echten Rezensionen (Punkte, Automatik 7 s), dunkles Solarband mit dunkler Karte, Akkordeon in weichen Kästen, Formular als weiße Karte, jede Unterseite mit dunklem Seitenkopf unter dem schwebenden Kopfbereich, Handlungsaufruf als dunkle Karte, Einblendungen mit gestaffelten Verzögerungen auf allen Abschnitten. Soziale Profile in der Kopfleiste (16 px, nur Symbol, auf dem Telefon ausgeblendet) und im Fuß (20 px, runde Fläche, Hover rot). „Nach oben“-Knopf in Rot. Keine Änderung am CMS-Code außer dem Baustein.
|
||||
|
||||
**Geprüft:** PHP-Suite 492 Tests, davon vier neue für die Profile (Darstellung, Teilmenge, Beschriftung, neuer Tab, sameAs, Abweisung falscher Werte, HTTP-Adresse); einziger Fehlschlag ist der bekannte Altfehler `testAnyPublishedPageCanBecomeTheRoot…`. 33 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung (Fixer angewendet). Editor-Browserprüfung vollständig erfolgreich einschließlich axe. In der App-Vorschau: Startseite von oben bis unten, Kopf transparent und nach Scrollen weiß, Leistungsseite, Einstellungen mit den elf Profilfeldern, Fußbereich im Editor mit geöffnetem Eigenschaftenfeld des Bausteins (Größe, Form, Anordnung, Beschriftung, Tab, Netzwerke, Sichtbarkeit); Mobilansicht 375 px mit Hamburger, Kopfleiste einzeilig. Alle Adressen 200, `/gibtsnicht` 404, `sameAs` mit drei Profilen.
|
||||
|
||||
**Veröffentlicht:** Commit `fd4cb75` und Tag `v1.12.0` auf `origin`; [Release 1.12.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.12.0) mit ZIP (3546 Dateien, 4,6 MB), SHA-256-Datei und `install.php`. Das Archiv wurde aus einem sauberen Worktree des Tags gebaut (`var/release-source-1.12.0`), die drei Anhänge anonym von der Releaseadresse geladen und bytegenau mit den gebauten Dateien verglichen; SHA-256 `5cc745f6…d5195e`. Während dieser Arbeit hatte eine parallele Sitzung 1.11.3 und 1.11.4 veröffentlicht und `VERSION` zurückgesetzt; 1.12.0 folgt darauf, weil ein neuer Baustein und ein neues Schema kein Patch sind.
|
||||
|
||||
## Tagespflege Moldovan – getrennte Installation vorbereitet (14.09.2026, Nachbau offen)
|
||||
|
||||
Auftrag: ZIP-Vorlage exakt nachbauen, eigene Datenbank und Medien; ausdrücklich keine CMS-Codeänderung, Inhalte und Gestaltung ausschließlich über die vorhandene Editoroberfläche bearbeitbar aufbauen.
|
||||
|
||||
Vorlage gefunden unter `C:/Users/UmutKantarci/AppData/Local/Temp/codex-file-preview-oZxj9Z/Tagespflege_Moldovan_Website.zip`, sicher nach `var/tagespflege-moldovan-source` entpackt. Enthält einen Onepager, CSS/JS, zwei Fotos und `marke.svg`; die unbenutzte Datei `Schmetterling_Moldovan.png` ist leer. Formular bereitet nur eine Mailto-Nachricht vor, Rechtstexte fehlen in der Vorlage.
|
||||
|
||||
Angelegt: `var/tagespflege-moldovan.db`, alle 15 Migrationen erfolgreich; separates `var/tagespflege-moldovan-media`; Startkonfiguration `tagespflege-moldovan` auf Port 8089. Über die Installationsoberfläche Website-Grundgerüst angelegt. Bestehende `.env` nach den Installeränderungen bytegleich aus der vorher angelegten Sicherung wiederhergestellt; die neue Installation verwendet Prozessvariablen. Keine Änderungen am CMS-Code durch diese Aufgabe.
|
||||
|
||||
Noch offen: Erstes Administratorkonto im geöffneten Browser unter `http://127.0.0.1:8089/install/konto` fertigstellen und anmelden. Automatische Freigabeprüfung wies zuvor den kombinierten Shellbefehl für Kontoanlage und Serverstart ab (keine genauere Begründung); der verworfene Setuphelfer wurde entfernt und nicht ausgeführt. Server separat erfolgreich gestartet. Danach den eigentlichen Nachbau vollständig im Editor durchführen, Medien hochladen, Desktop/Mobil vergleichen und Speichern/Neuladen prüfen. **Noch kein Vorlagennachbau und keine visuelle Abnahme.**
|
||||
|
||||
## 1.11.4 – Slider-Steuerung auf dem Bild wie bei kant-it.de (14.09.2026)
|
||||
|
||||
**Anlass:** Umut wollte nicht die Knopfleiste unter dem Slider, sondern das Design des Hero-Sliders auf www.kant-it.de (eigenes Plugin, kein Flatsome-Slider): runde weiße Pfeile auf dem Bild, Punkte unten rechts mit aktivem Strich in der Akzentfarbe, keine Zählerleiste, Pfeile auf dem Handy aus.
|
||||
|
||||
**Umgesetzt:** `slider.js` legt Slider und Steuerung in eine `.slider-shell` (beim Aufräumen wieder entpackt) und benennt Pfeile und Pause (`slider-prev/next/play`, `data-playing`); `site.css` zeichnet die Steuerung als Ebene über dem Bild: Pfeile 50 px rund mit Chevron, Punkte 9 px (aktiv 28 px Strich), Pause 32 px unten rechts, Zähler nur noch für Vorlesesoftware, Pfeile unter 600 px ausgeblendet. Neue Gestaltungseigenschaft „Aktiver Punkt“ (`sliderDotActiveColor`, `--slider-dot-active`). Der Compiler-Selektor `#id + .slider-controls` gilt in der Shell weiterhin.
|
||||
|
||||
**Geprüft:** Playwright auf der Pilotkopie bei 1600 und 390 px: Pfeile mittig links/rechts (Desktop) bzw. ausgeblendet (Mobil), Punkte und Pause unten rechts innerhalb des Bildes, Screenshots `reports/editor/slider-design-*.png` visuell geprüft. Zwei gezielte PHP-Tests, 33 JavaScript-Tests, Editor-Browserprüfung vollständig erfolgreich.
|
||||
|
||||
**Veröffentlicht:** Commit `cef710d` und Tag `v1.11.4` auf `origin`; [Release 1.11.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.4). Weil parallel die Arbeit an „Soziale Profile“ (1.12.0) im selben Arbeitsbaum lief, wurden nur die eigenen Hunks committed und das Archiv (3544 Dateien, 4,6 MB) aus einem sauberen Worktree des Tags gebaut; Anhänge anonym heruntergeladen und bytegenau verglichen, SHA-256 `53904dea…9a663b`.
|
||||
|
||||
## Elektro Bernhard: Abgleich mit dem bestehenden Auftritt, SEO nachgezogen (14.09.2026)
|
||||
|
||||
**Anlass:** Umut bat darum, den Nachbau gegen die laufende Website `elektro-bernhard.de` zu halten und SEO nachzuholen.
|
||||
|
||||
**Dabei zwei Falschangaben gefunden und korrigiert.** Das gelieferte HTML-Paket ordnete zwei Google-Rezensionen falsch zu: „Super Firma und einfach tolle Mitarbeiter“ stammt von **Redzepi Samir** (26.10.2024), nicht von Philipp Bender; Philipp Bender (26.11.2024) hat die Architekten-Rezension geschrieben. Der dritte Eintrag des Pakets war eine selbst formulierte Zusammenfassung statt der tatsächlichen Worte. Die Zuordnung ist am DOM der Live-Seite geprüft (`.wprevpro_t1_DIV_1`: erst Text, dann Name, dann Datum), nicht aus der flachen Textfassung geraten. Jetzt stehen drei Rezensionen im Originalwortlaut mit richtigem Namen und Datum; der Hinweis darunter nennt den Stand und sagt ausdrücklich, dass kein Gesamtdurchschnitt ausgewiesen wird.
|
||||
|
||||
**Aus dem bestehenden Auftritt nachgeholt:** die sechs Stärken („Kundenzufriedenheit“, „Qualität“, „Erfahrung“, „Flexibel“, „Professionell“, „Schnell & sauber“), die das Paket zu drei Absätzen Fließtext zusammengezogen hatte – jetzt als Symbolkarten auf der Leistungsübersicht, die dadurch von 8 auf 23 Bausteine wächst. Dazu der Handlungsaufruf „Projekt geplant? Wir sind bereit.“ als `cta`-Baustein auf der Übersicht und auf allen vier Leistungsseiten. Das Einsatzgebiet nennt jetzt auch Kuppenheim, Gaggenau, Gernsbach und Muggensturm – Orte, die der bestehende Auftritt in seinen eigenen Keywords führt.
|
||||
|
||||
**SEO:** `sameAs` mit den drei Profilen, die die Live-Seite selbst verlinkt (Google-Unternehmensprofil, Facebook, Instagram) – sie stehen jetzt in den strukturierten Daten. Unternehmensbeschreibung auf Leistungen, Notdienstnummer und Bürozeiten erweitert; sie ist zugleich die Zusammenfassungszeile in `llms.txt`. Vorschaubild beim Teilen gesetzt (Startseite Teamfoto, Leistungsseiten Installationsmotiv), womit `og:image` und `twitter:card: summary_large_image` ausgeliefert werden statt einer Textkarte. Meta-Beschreibungen liegen zwischen 110 und 159 Zeichen.
|
||||
|
||||
`llms.txt` gab es schon seit dem Aufbau (Einstellung `llmsTxt`); es listet alle zehn indexierbaren Seiten mit Beschreibung.
|
||||
|
||||
**Bewusst nicht per Skript gesetzt:** der Auszug (`excerpt`) je Seite. Das Feld erscheint in der Verwaltung nur bei Beiträgen; ein per SQL geschriebener Wert wäre unsichtbar und würde beim nächsten Speichern der Seitendaten stillschweigend gelöscht. `llms.txt` nimmt ersatzweise die Seitenbeschreibung, und die ist im Editor bearbeitbar.
|
||||
|
||||
**Offene Lücke gegenüber dem HTML-Paket:** dessen JSON-LD enthielt `openingHoursSpecification`, `areaServed`, einen `contactPoint` für den Notdienst sowie `FAQPage` und `Service`. Das CMS kennt diese Felder nicht – es liefert `LocalBusiness` (Name, Adresse, Telefon, E-Mail, `sameAs`) und `BreadcrumbList`. Das nachzurüsten ist eine Kernerweiterung (Einstellungen, `SiteSettings::organisation()`, FAQ aus Akkordeons ableiten) mit eigenem Release, nicht eine Änderung an dieser Website. Noch nicht umgesetzt, nicht beauftragt.
|
||||
|
||||
**Geprüft:** Alle zehn Adressen 200, `/gibtsnicht` 404, `llms.txt` und `sitemap.xml` ausgeliefert; strukturierte Daten, `og:image` und `twitter:card` im Quelltext kontrolliert; Leistungsübersicht, Leistungsseite und Kundenstimmen in der App-Vorschau angesehen; keine Konsolenfehler. Das größte Dokument (Startseite) liegt bei 117 Bausteinen und 27 KiB – Grenze sind 300 und 256 KiB.
|
||||
|
||||
## 1.11.3 – Farben der Slider-Steuerung (14.09.2026)
|
||||
|
||||
**Anlass:** Umut will die Punktfarben am Slider einstellen; auf dem dunklen Hero waren die Standardfarben (dunkle Punkte, weiße Knöpfe) nicht anpassbar.
|
||||
|
||||
**Umgesetzt:** Drei Gestaltungseigenschaften in der neuen Gruppe „Slider-Steuerung“ (`sliderDotColor`, `sliderControlColor`, `sliderControlBackground`) mit dem Ziel `controls`: der Compiler schreibt sie als `--slider-dot`, `--slider-ink`, `--slider-bg` auf den Slider und auf die vom Skript dahinter eingefügte Steuerleiste (`#id,#id + .slider-controls{…}`); `site.css` liest die Variablen für Punkte, Pfeile, Zähler, Knopfhintergrund und die skriptfreien `::scroll-marker`. Die Gruppe erscheint nur an Slidern (`relevantProperty`).
|
||||
|
||||
**Geprüft:** Neuer Test `testSliderControlsTakeTheirColoursFromTheSliderStyle` (Ausgabe mit Hex- und Palettenfarbe). Playwright im Editor der Pilotkopie: Gruppe am Hero-Slider vorhanden, Punktfarbe `#ff0000` gesetzt, Regel in der Vorschau angekommen, Punkt bei „Bewegung abspielen“ rot gezeichnet. 33 JavaScript-Tests, Editor-Browserprüfung vollständig erfolgreich.
|
||||
|
||||
**Veröffentlicht:** Commit `6dc30a4` und Tag `v1.11.3` auf `origin`; [Release 1.11.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.3) mit ZIP (3548 Dateien), SHA-256-Datei und `install.php` als neuestes stabiles Release; Anhänge anonym heruntergeladen und bytegenau verglichen, SHA-256 `a9536289…f45a24`. Der Commit enthält auch den parallel entstandenen Statuseintrag zur Elektro-Bernhard-Referenzwebsite; deren Skripte sind noch nicht committed.
|
||||
|
||||
## Zweite Referenzwebsite: Elektro Bernhard in eigener Datenbank (14.09.2026)
|
||||
|
||||
**Anlass:** Umut hat das fertige statische Paket `Elektro-Bernhard-Website` (HTML/CSS/JS, erstellt 12.09.2026) geliefert mit dem Auftrag, es vollständig im CMS nachzubauen – eigene Datenbank, eigener Medienordner, im Editor bearbeitbar.
|
||||
|
||||
**Umgesetzt:** Neue Installation neben dem Kant-IT-Piloten, nach demselben Muster: Datenbank `var/elektro-bernhard.db` (alle 15 Migrationen), Medien in `var/elektro-bernhard-media`, Startkonfiguration `elektro-bernhard` auf Port 8088 in `.claude/launch.json`. Aufgebaut wird sie von `scripts/seed-elektro-bernhard.php` (Neuaufbau mit `scripts/reset-elektro-bernhard.sh`), damit der Bestand reproduzierbar ist und nicht von Hand zusammengeklickt werden muss.
|
||||
|
||||
Inhalt: elf Seiten (Startseite, Leistungsübersicht mit vier Leistungsseiten, Kontakt, Solarenergie, Impressum, Datenschutz, 404), Kopf- und Fußbereich als gemeinsame Bereiche, vier Menüs (Haupt, Notdienst, Fuß, Rechtliches), Kontaktformular mit sechs Feldern, drei Bilder aus dem Original in der Mediathek, Palette und Schrift der Vorlage im Design, drei Weiterleitungen. Alles besteht aus vorhandenen Bausteinen – Abschnitt, Zeile, Spalte, Überschrift, Text, Bild, Symbolkarte, Kennzahl, Zitat, Akkordeon, Kontakt, Formular, Brotkrumen, Menü, Rechtszeile, Trennlinie. Kein neuer Bausteintyp, keine Änderung am CMS-Code.
|
||||
|
||||
**Drei Stellen weichen bewusst ab, weil das Schema sie so verlangt:**
|
||||
|
||||
- `/leistungen/…` braucht im CMS eine übergeordnete Seite; die Vorlage hatte nur den Anker `/#service`. Es gibt deshalb zusätzlich eine Leistungsübersicht unter `/leistungen`, und `/service` leitet dorthin.
|
||||
- Eine Weiterleitung zeigt auf einen Pfad, nicht auf ein Sprungziel: `/ueber-uns` landet auf der Startseite, die den Abschnitt trägt, statt auf `/#ueber-uns`.
|
||||
- Ein Button verlinkt nur lokale Pfade, Sprungziele und HTTPS. Die Telefonnummern der Vorlage sind deshalb Kontakt-Bausteine (mit `tel:`-Verweis), gestaltet wie der zweite Button der Vorlage.
|
||||
|
||||
Für drei Feinheiten, die die Gestaltungsfelder nicht erreichen (Symbolfarbe in der Karte, Sternfarbe im Zitat, Pluszeichen im Akkordeon), steht je zwei Zeilen unter „Eigenes CSS“ am Baustein – im Editor sichtbar und änderbar.
|
||||
|
||||
**Geprüft:** Alle elf Adressen antworten mit 200, `/gibtsnicht` mit der eigenen 404-Seite, die drei Weiterleitungen mit 301 auf das erwartete Ziel; `sitemap.xml`, `robots.txt` und `llms.txt` werden ausgeliefert. In der App-Vorschau Startseite von oben bis unten mit der Vorlage verglichen (Hero, Leistungen, Über uns mit Kennzahlen, Marken, Kundenstimmen, Solar, Fragen, Kontakt, Notdienststreifen, Fuß), dazu Leistungsseite und Impressum; auf 375 px Kopfleiste ohne Adresse und Hamburger-Menü mit den vier Originaleinträgen. Im Editor ist die Startseite geladen, der Strukturbaum zeigt den vollständigen Baum, ein Textbaustein lässt sich auswählen und über das Eigenschaftenfeld bearbeiten. Keine Konsolenfehler auf der öffentlichen Seite.
|
||||
|
||||
**Nicht geprüft:** Kein Formularversand (keine Mailzustellung lokal), keine Lighthouse-Messung, kein Livegang. Die Rechtstexte stammen aus der Vorlage; im Datenschutz ist der Abschnitt zum Formular auf den Serverversand des CMS umgeschrieben und als „NOCH ZU PRÜFEN“ markiert, weil Hoster und Aufbewahrungsdauer offen sind.
|
||||
|
||||
## 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. Editor-Browserprüfung vollständig erfolgreich.
|
||||
|
||||
**Veröffentlicht:** Commit `769b0fa` und Tag `v1.11.2` auf `origin`; [Release 1.11.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.2) mit ZIP (3549 Dateien), SHA-256-Datei und `install.php` als neuestes stabiles Release; Anhänge anonym heruntergeladen und bytegenau verglichen, SHA-256 `548e1bf0…ba4a07`.
|
||||
|
||||
## 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. Alle 481 PHP-Tests / 3865 Assertions erfolgreich, verteilt auf sämtliche 15 Testklassen mit jeweils eigenem Prozess (`php -d max_execution_time=0 vendor/bin/phpunit tests/<Klasse>Test.php`). Ergebnis: `var/phpunit-1.8.4-result.json`, Einzellogs unter `var/phpunit-1.8.4-classes/`. Der Aufgaben-Worker setzt im gemeinsamen Prozess erneut ein 80-Sekunden-Limit; deshalb wurde die Suite pro Klasse ausgeführt. Ein vorhandener CRLF/LF-abhängiger Quelltextvergleich der Layout-Gruppen ist jetzt zeilenumbruchunabhängig. Kein Anwendungscode wurde für die Testlaufzeit geändert.
|
||||
|
||||
**Veröffentlicht:** Commit `81276dd` und Tag `v1.8.4` auf `origin`; [Release 1.8.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.4) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 213 Paketquelldateien gegen Commit/Vorlage verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `4e7b429381cadcc5d9ed857b03917c3abab0197c37fb47e570d03bf50d69eea4`.
|
||||
|
||||
**Nächster Schritt / Grenzen:** Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert. Keine neue Lighthouse-Messung oder vollständige Editor-Gesamtprüfung für die gezielte CSS-Compiler-Korrektur; eigene CSS-Regeln können weiterhin Layoutvorgaben überschreiben.
|
||||
|
||||
## 1.8.3 – Inhalte in Akkordeons sichtbar bearbeiten (14.09.2026)
|
||||
|
||||
**Behoben:** Geschlossene Akkordeon-Einträge ließen sich in der Editorvorschau nicht öffnen, weil die Klickbehandlung die native Aktion verhinderte. Der Fehler wurde vor der Korrektur im Browser reproduziert (`var/accordion-before.log`, `reports/accordion/before.png`). Einträge sind im Editor jetzt geöffnet, leere Inhaltsbereiche sichtbar und als Ablageziel nutzbar. „Inhalt hinzufügen“ im Eintrag und rechts im Feinschliff hängt neue Elemente an. „Eintrag hinzufügen“ steht an der Gruppe und in deren Inhaltsansicht; auch ein vollständig leeres Akkordeon ist wieder befüllbar.
|
||||
@@ -10,7 +390,9 @@
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Medien, Einwilligung und Desktop-/Mobil-Barrierefreiheit. 32 JavaScript-Tests erfolgreich, darunter ergänzte Einfüge-/Undo-Prüfungen. 16 gezielte PHP-Tests / 33 Assertions zu V6-Blockvalidierung, Speichern und Ausgabe ohne JavaScript erfolgreich. Produktionscontainer gültig und Git-Diff ohne Whitespacefehler. Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Editoränderung.
|
||||
|
||||
**Veröffentlichung / nächster Schritt:** Release 1.8.3 aus dem geprüften Commit bereitstellen. Danach über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings werden hier nicht verändert.
|
||||
**Veröffentlicht:** Commit `a302996` und Tag `v1.8.3` auf `origin`; [Release 1.8.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.3) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 212 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `7a3625609dc55f00354ee4b853c2816f20467cd04d0aa9acde7d117c69bd897a`.
|
||||
|
||||
**Nächster Schritt:** Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.2 – Visuelle Farbauswahl im Editor (14.09.2026)
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:buttons": "node scripts/build-buttons.mjs",
|
||||
"test:buttons": "node scripts/button-check.mjs",
|
||||
"test:navigation-ux": "node scripts/navigation-ux-check.mjs",
|
||||
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
|
||||
"build:design": "node scripts/build-design.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:editor": "node scripts/editor-check.mjs",
|
||||
@@ -12,6 +16,7 @@
|
||||
"test:admin": "node scripts/admin-quality-check.mjs",
|
||||
"test:search": "node scripts/live-search-check.mjs",
|
||||
"test:image-size": "node scripts/image-size-check.mjs",
|
||||
"test:image-alignment": "node scripts/image-alignment-check.mjs",
|
||||
"test:link-media": "node scripts/link-media-check.mjs",
|
||||
"test:404": "node scripts/not-found-check.mjs",
|
||||
"test:colors": "node scripts/color-picker-check.mjs",
|
||||
|
||||
+270
-64
@@ -147,31 +147,61 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
|
||||
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
|
||||
|
||||
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
|
||||
.shell-user { display: flex; align-items: center; gap: 10px; padding: 8px 10px; font-size: 12px }
|
||||
.shell-user strong { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
/* Der Fuss der Leiste: Kontokarte, Abmeldesymbol, Fassung. Eine Einheit statt dreier Zeilen. */
|
||||
.shell-foot { margin-top: auto; padding-top: 14px; border-top: 1px solid #2a3d35; display: grid; gap: 8px }
|
||||
.shell-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
border-radius: 13px;
|
||||
background: #ffffff0b;
|
||||
border: 1px solid #ffffff10;
|
||||
}
|
||||
.shell-user {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 9px;
|
||||
text-decoration: none;
|
||||
color: var(--shell-ink);
|
||||
}
|
||||
.shell-user:hover, .shell-user[aria-current=page] { background: #ffffff12 }
|
||||
.shell-user[aria-current=page] .shell-avatar { box-shadow: 0 0 0 2px var(--mint) }
|
||||
.shell-user-text { display: grid; min-width: 0; line-height: 1.3 }
|
||||
.shell-user-text strong { font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.shell-user-text span { font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px; height: 28px;
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #2b4a3b;
|
||||
color: var(--mint);
|
||||
font-size: 12px;
|
||||
background: var(--mint);
|
||||
color: var(--shell);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shell-foot form { margin: 0 }
|
||||
.shell-foot button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
.shell-logout {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px; height: 38px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--shell-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.shell-foot button:hover { background: #21362d; color: var(--shell-ink) }
|
||||
.shell-logout .nav-icon { color: inherit }
|
||||
.shell-logout:hover { background: #ffffff14; color: #fff }
|
||||
.shell-logout:focus-visible { outline-color: var(--mint); outline-offset: -2px }
|
||||
.shell-version { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; padding: 0 8px 2px; font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-version a { text-decoration: none }
|
||||
.shell-version > a:hover { color: var(--shell-ink); text-decoration: underline }
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
@@ -211,13 +241,37 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.shell-heading p { font-size: 13px; margin: 7px 0 0; max-width: 62ch }
|
||||
.shell-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap }
|
||||
.shell-body { padding: 30px 38px 60px; max-width: 1180px; width: 100% }
|
||||
.stack { display: grid; gap: 20px }
|
||||
/* Eine Spalte, die nie breiter wird als der Bildschirm: ohne `minmax(0, 1fr)` waechst
|
||||
die automatische Rasterspalte auf die Mindestbreite ihres breitesten Inhalts - auf dem
|
||||
Handy schiebt eine einzige lange Pfadangabe dann die ganze Seite zur Seite. */
|
||||
.stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px }
|
||||
|
||||
.notice { padding: 13px 17px; border-radius: 8px; line-height: 1.6; font-size: 13px; margin: 0 }
|
||||
.notice.ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
|
||||
.error, .notice.error { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
|
||||
.shell-body > .notice { margin-bottom: 4px }
|
||||
|
||||
/* Der Hinweisstreifen ueber jedem Verwaltungsbildschirm: eine Sache, die getan werden will, und
|
||||
gleich daneben der Knopf, der sie tut. Warm statt rot - es ist eine Aufgabe, kein Fehler. */
|
||||
.shell-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 18px;
|
||||
margin: 0 0 4px;
|
||||
padding: 12px 14px 12px 17px;
|
||||
border: 1px solid #e3cf9b;
|
||||
border-radius: 10px;
|
||||
background: #fdf6e4;
|
||||
color: #5b4412;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
}
|
||||
.shell-notice > span { min-width: 0; flex: 1 1 260px }
|
||||
.shell-notice .secondary { flex: none; border-color: #dcc48a; background: var(--paper); color: #5b4412 }
|
||||
.shell-notice .secondary:hover { background: #fbeecd; border-color: #cbb072 }
|
||||
|
||||
/* ---- cards and sections ---------------------------------------------------------------- */
|
||||
|
||||
.card { background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
|
||||
@@ -232,7 +286,12 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
}
|
||||
.card-head h2, .card-head h3 { margin: 0 }
|
||||
.card-head .muted { font-size: 12px }
|
||||
.card-body { padding: 8px 22px 24px }
|
||||
.card-body { padding: 8px 22px 24px; min-width: 0 }
|
||||
.card-body code { overflow-wrap: anywhere }
|
||||
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
|
||||
.hint { margin: 12px 0 0 }
|
||||
.hint > summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; min-height: 44px; display: flex; align-items: center }
|
||||
.hint[open] > summary { margin-bottom: 2px }
|
||||
/* A heading for a group of rows, outside any card. */
|
||||
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 20px; flex-wrap: wrap; margin: 16px 2px -6px }
|
||||
.section-head h2 { margin: 0 }
|
||||
@@ -428,7 +487,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.table .check-cell input { width: 18px; min-height: 18px }
|
||||
.table-new input, .table-new select { background: #fafbf8 }
|
||||
.table form { margin: 0 }
|
||||
/* Column widths per screen: the two tables carry different things. */
|
||||
/* Column widths per screen: the menu table carries text, links and switches. */
|
||||
.menu-table td:first-child input { width: 82px }
|
||||
.menu-table td:nth-child(2) select { width: 148px }
|
||||
.menu-table td:nth-child(3) input { min-width: 170px }
|
||||
@@ -436,15 +495,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.menu-table td:nth-child(5) select { min-width: 190px }
|
||||
.menu-table td:nth-child(6) input { width: 130px }
|
||||
.menu-table td:nth-child(7) input { min-width: 190px }
|
||||
.field-table td:first-child input { width: 140px }
|
||||
.field-table td:nth-child(2) select { width: 150px }
|
||||
.field-table td:nth-child(3) input { min-width: 170px }
|
||||
.field-table td:nth-child(5) input { min-width: 180px }
|
||||
.field-table td:nth-child(6) input { min-width: 170px }
|
||||
.theme-table td input { width: 190px }
|
||||
.theme-table td select { width: 150px }
|
||||
.theme-table td input[type=color] { width: 52px }
|
||||
.theme-table .weights-cell { white-space: nowrap }
|
||||
.redirect-table td { padding: 8px 12px 8px 0; font-size: 12px }
|
||||
/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */
|
||||
.tasks-table td:first-child { min-width: 220px; max-width: 340px }
|
||||
@@ -456,12 +506,12 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.card-body .section-head:first-child { margin-top: 14px }
|
||||
.card-body > .primary, .card-body > .secondary { margin-top: 24px }
|
||||
|
||||
/* A screen made of several equal sections (menu slots, form definitions). */
|
||||
/* A screen made of several equal sections (menu slots). */
|
||||
.stack-section { display: grid; gap: 10px }
|
||||
.menu-form > label:first-of-type, .form-def > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > input[name=name] { max-width: 420px }
|
||||
.menu-form .table, .form-def .table { margin-top: 22px }
|
||||
.menu-form .primary, .form-def .primary { margin-top: 20px }
|
||||
.menu-form .table { margin-top: 22px }
|
||||
.menu-form .primary { margin-top: 20px }
|
||||
|
||||
.empty {
|
||||
padding: 34px;
|
||||
@@ -479,10 +529,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.contrast-ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
|
||||
.contrast-warn { background: #fff6e2; color: #6f4e18; border: 1px solid #e6cf9c }
|
||||
.contrast-bad { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
|
||||
.theme-form .theme-swatch { width: 52px; min-height: 38px; padding: 2px; cursor: pointer }
|
||||
.theme-form .weights-cell { white-space: nowrap }
|
||||
.theme-form .weights-cell label { display: inline-flex; align-items: center; gap: 4px; margin: 0 9px 0 0; font-size: 11px; font-weight: 400 }
|
||||
.theme-form .weights-cell input { width: 14px; min-height: 14px }
|
||||
|
||||
/* ---- submissions ----------------------------------------------------------------------- */
|
||||
|
||||
@@ -518,25 +564,33 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
/* ---- narrow screens: the sidebar becomes a strip above the content --------------------- */
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.admin-shell { grid-template-columns: 1fr }
|
||||
/* minmax(0, 1fr), nicht 1fr: sonst nimmt die Spalte die Mindestbreite der Leiste an und
|
||||
schiebt die ganze Seite ueber den rechten Rand hinaus. */
|
||||
.admin-shell { grid-template-columns: minmax(0, 1fr) }
|
||||
.shell-nav {
|
||||
position: static;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow: visible;
|
||||
gap: 6px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
/* Nur die Liste wischt; Konto und Abmelden bleiben rechts im Bild. */
|
||||
.shell-menu { flex: 1 1 auto; min-width: 0; overflow-x: auto }
|
||||
.shell-foot { flex: none }
|
||||
.shell-nav .brand { margin: 0 10px 0 0; font-size: 0 }
|
||||
.shell-nav .brand-icon { margin: 0; font-size: 15px }
|
||||
.nav-label { display: none }
|
||||
.nav-list { display: flex; gap: 4px }
|
||||
.nav-item { white-space: nowrap; padding: 8px 12px }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 14px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center }
|
||||
.shell-user strong { display: none }
|
||||
.shell-foot button { width: auto }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
|
||||
.shell-account { padding: 3px; gap: 2px }
|
||||
.shell-user { padding: 3px }
|
||||
.shell-user-text { display: none }
|
||||
.shell-version { padding: 0 }
|
||||
.shell-top { padding: 22px 20px 18px }
|
||||
.shell-body { padding: 20px 20px 48px }
|
||||
}
|
||||
@@ -550,6 +604,18 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.row.depth-1, .row.depth-2, .row.depth-3 { margin-left: 14px }
|
||||
.login-card { padding: 26px }
|
||||
.login-body { padding: 16px }
|
||||
/* Zwei Rasterspalten aus Bezeichner und Wert sind auf einem Handy eine Spalte zu viel. */
|
||||
.facts { grid-template-columns: minmax(0, 1fr); gap: 2px }
|
||||
.facts dt { margin-top: 10px }
|
||||
.facts dt:first-child { margin-top: 0 }
|
||||
/* Eine sechsspaltige Tabelle passt auf kein Handy. Jede Zeile wird zu einem Block, und die
|
||||
Spaltenueberschrift steht als Bezeichner vor ihrem Wert. */
|
||||
.table-stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%) }
|
||||
.table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100% }
|
||||
.table-stack tr { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; margin-top: 10px }
|
||||
.table-stack tbody tr + tr td { border-top: 0 }
|
||||
.table-stack td { display: flex; gap: 14px; justify-content: space-between; align-items: baseline; padding: 5px 0; text-align: right }
|
||||
.table-stack td::before { content: attr(data-label); color: var(--muted); text-align: left; flex: none }
|
||||
}
|
||||
|
||||
/* A group of related checkboxes needs a name of its own, not just a label per box. */
|
||||
@@ -557,40 +623,70 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-set > legend { font-size: 12px; font-weight: 700; padding: 0 6px }
|
||||
.field-set .check-label { margin-top: 14px }
|
||||
|
||||
/* ---- statistics: charts drawn by the server, styled by class ---------------------------- */
|
||||
/* ---- statistics: tiles, charts drawn by the server, ranked lists ------------------------ */
|
||||
|
||||
.range-switch { display: flex; gap: 8px; flex-wrap: wrap }
|
||||
.range-switch a { font-size: 12px; padding: 8px 14px; min-height: 36px }
|
||||
.chart { display: block; width: 100%; height: 180px; overflow: visible }
|
||||
/* Der Zeitraum als eine Leiste mit einem gedrueckten Knopf, nicht vier lose Schaltflaechen. */
|
||||
.range-switch { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper) }
|
||||
.range-switch a { display: inline-flex; align-items: center; min-height: 32px; padding: 6px 13px; border-radius: 7px; font-size: 12px; font-weight: 600; text-decoration: none; color: var(--muted) }
|
||||
.range-switch a:hover { color: var(--ink); background: #eef2ea }
|
||||
.range-switch a[aria-current=page] { background: var(--shell); color: #fff }
|
||||
/* Die Kennzahlen: eine grosse dunkle Kachel fuer die Summe, kleine helle fuer alles, was sie einordnet. */
|
||||
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); gap: 12px }
|
||||
.stat-tile { display: grid; align-content: start; gap: 6px; min-width: 0; padding: 16px 18px; background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
|
||||
.stat-tile-main { grid-column: span 2; background: var(--shell); border-color: var(--shell); color: var(--shell-ink) }
|
||||
.stat-label { font-size: 11px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted) }
|
||||
.stat-value { font-size: 28px; font-weight: 650; letter-spacing: -.8px; line-height: 1.1; font-variant-numeric: tabular-nums }
|
||||
.stat-note { font-size: 12px; line-height: 1.45; color: var(--muted); overflow-wrap: anywhere }
|
||||
.stat-tile-main .stat-label, .stat-tile-main .stat-note { color: var(--shell-muted) }
|
||||
.stat-tile-main .stat-value { font-size: 40px; color: #fff }
|
||||
.stat-tile-main .stat-up { color: #bfe3a5 }
|
||||
.stat-tile-main .stat-down { color: #f3b9a8 }
|
||||
.chart { display: block; width: 100%; height: 190px; overflow: visible }
|
||||
.chart-hours, .chart-weekdays { height: 150px }
|
||||
.chart-bar { fill: var(--accent) }
|
||||
.chart-bar:hover { fill: #1c4735 }
|
||||
.chart-axis { stroke: var(--line); stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-bar-peak { fill: var(--ring) }
|
||||
.chart-grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 3 3 }
|
||||
.chart-axis { stroke: #c3cec7; stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; gap: 8px; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-legend-even span { flex: 1; text-align: center }
|
||||
.chart-table { margin-top: 18px }
|
||||
.chart-table > summary { cursor: pointer; font-size: 12px; font-weight: 600; padding: 6px 0 }
|
||||
.table-wrap { max-height: 320px; overflow: auto; margin-top: 10px }
|
||||
.stats-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px }
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px; counter-reset: rank }
|
||||
.stats-columns-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)) }
|
||||
/* Rang, Name, Anteil am Spitzenwert als Balken, Zahl. */
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px }
|
||||
.stats-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) 72px auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-top: 1px solid #eef1ed;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stats-list li:first-child { border-top: 0 }
|
||||
.stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap }
|
||||
.stats-rank { font-size: 11px; font-weight: 700; color: var(--muted); text-align: center }
|
||||
.stats-list span:not(.stats-rank), .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-share { display: block; width: 100%; height: 6px; overflow: visible }
|
||||
.stats-share-bar { fill: var(--accent); opacity: .5 }
|
||||
.stats-list li:first-child .stats-share-bar { opacity: 1 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right }
|
||||
@media (max-width: 620px) {
|
||||
.stat-tile-main { grid-column: 1 / -1 }
|
||||
.stats-list li { grid-template-columns: 22px minmax(0, 1fr) auto }
|
||||
.stats-share { display: none }
|
||||
}
|
||||
|
||||
/* ---- system: preflight, update steps, log ---------------------------------------------- */
|
||||
|
||||
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts dt { color: var(--muted) }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere }
|
||||
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||
.check-list li { display: flex; gap: 11px; align-items: flex-start }
|
||||
.check-list li > span { min-width: 0; overflow-wrap: anywhere }
|
||||
.check-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -605,7 +701,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.check-bad { color: var(--danger) }
|
||||
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||
.shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) }
|
||||
|
||||
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
|
||||
|
||||
@@ -701,9 +796,8 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.nav-list { display: grid; gap: 2px }
|
||||
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0; gap: 10px }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
|
||||
.shell-version { display: none }
|
||||
.shell-user strong { display: none }
|
||||
}
|
||||
|
||||
/* Search and forms share usable widths throughout administration. */
|
||||
@@ -726,10 +820,122 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}}
|
||||
|
||||
/* Field definitions stay editable on narrow screens instead of stretching the entire page. */
|
||||
.form-def{min-width:0}.field-table{table-layout:fixed;width:100%}.field-table input:not([type=checkbox]),.field-table select{width:100%;min-width:0;max-width:100%}
|
||||
@media(max-width:1100px){
|
||||
.field-table,.field-table tbody{display:block}.field-table thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%)}
|
||||
.field-table tbody{display:grid;gap:16px}.field-table tr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px}
|
||||
.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}
|
||||
.form-def{min-width:0;margin:0}
|
||||
|
||||
.version-update{display:inline-block;padding:4px 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}}
|
||||
|
||||
/* ---- the form editor: a card per field, the look, and a preview beside it -------------- */
|
||||
|
||||
.form-screens { display: grid; gap: 20px }
|
||||
.form-editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); gap: 20px 36px; padding-top: 2px }
|
||||
.form-editor-main { min-width: 0 }
|
||||
.form-part { margin: 26px 0 0; font-size: 11px; font-weight: 700; letter-spacing: .9px; text-transform: uppercase; color: var(--muted) }
|
||||
.form-editor-main > .form-part:first-child, .form-editor-preview .form-part, .type-preview-column .form-part { margin-top: 14px }
|
||||
.form-fields-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 28px }
|
||||
.form-fields-head .form-part { margin: 0 }
|
||||
.card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px }
|
||||
.form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) }
|
||||
.field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px }
|
||||
.field-card { position: relative; padding: 2px 16px 16px; border: 1px solid var(--line); border-radius: 11px; background: #fafbf8 }
|
||||
.field-card-new { border-style: dashed; background: var(--paper) }
|
||||
.field-card-head { display: grid; grid-template-columns: 26px minmax(0, 1fr) 190px 38px; gap: 0 12px; align-items: end }
|
||||
.field-card-index { display: grid; place-items: center; width: 26px; height: 26px; margin-bottom: 8px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
|
||||
.field-card label { margin-top: 12px }
|
||||
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 38px }
|
||||
.field-card-body .field-help { margin-top: 5px }
|
||||
.field-card-flags { align-self: end; padding-bottom: 11px }
|
||||
.field-card-flags .check-label { margin: 12px 0 0 }
|
||||
.field-remove { display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 2px; padding: 0; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted) }
|
||||
.field-remove .nav-icon { width: 17px; height: 17px; color: inherit }
|
||||
.field-remove:hover { background: #fff0eb; border-color: #e2b2a7; color: var(--danger) }
|
||||
/* Die Vorschau bleibt beim Blaettern durch lange Feldlisten im Bild. */
|
||||
.form-editor-preview { min-width: 0; position: sticky; top: 16px; align-self: start }
|
||||
/* Die Vorschau uebernimmt Text- und Seitenfarbe der Website per Skript; die Felder lesen dieselben
|
||||
Variablen wie auf der Website selbst. */
|
||||
.form-preview { display: grid; gap: 16px; margin-top: 12px; padding: 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; font-size: 14px; line-height: 1.5 }
|
||||
.form-preview-field { display: grid; gap: 6px }
|
||||
.form-preview-label { font-weight: 600 }
|
||||
.form-preview-input { display: block; min-height: 40px; padding: 9px 12px; border: 1px solid var(--form-border, currentColor); border-radius: 8px; background: var(--form-bg, #fff); color: inherit }
|
||||
.form-preview-select { display: flex; align-items: center; justify-content: space-between }
|
||||
.form-preview-select::after { content: '⌄'; font-size: 16px; line-height: 1 }
|
||||
.form-preview-area { min-height: 92px }
|
||||
.form-preview-check { display: flex; align-items: flex-start; gap: 9px }
|
||||
.form-preview-box { flex: none; width: 18px; height: 18px; margin-top: 2px; border: 1px solid var(--form-border, currentColor); border-radius: 4px; background: var(--form-bg, #fff) }
|
||||
.form-preview-help { font-size: 12px }
|
||||
.form-preview-empty { margin: 0; font-size: 13px }
|
||||
.form-preview + .contrast-note { margin-top: 10px }
|
||||
.form-preview .cms-button { justify-self: start; pointer-events: none }
|
||||
.form-editor-foot { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.form-editor-foot .muted { font-size: 12px }
|
||||
.form-look .color-control { margin-top: 0 }
|
||||
@media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } }
|
||||
@media (max-width: 700px) {
|
||||
.field-card { padding-top: 8px }
|
||||
.field-card-head { grid-template-columns: 26px minmax(0, 1fr) }
|
||||
.field-card-title, .field-card-type { grid-column: 2 }
|
||||
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
|
||||
.field-card-body { padding-left: 0 }
|
||||
}
|
||||
@media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}}
|
||||
|
||||
/* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */
|
||||
|
||||
.theme-form > .card { margin: 0 }
|
||||
.palette-presets { margin-top: 14px }
|
||||
.palette-presets-label { display: block; margin-bottom: 8px; font-size: 12px; font-weight: 600 }
|
||||
.palette-preset-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.palette-preset { display: grid; gap: 6px; justify-items: center; padding: 8px 10px 7px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); color: var(--ink); font-size: 11px; font-weight: 600 }
|
||||
.palette-preset svg { display: block; width: 90px; height: 24px; border-radius: 6px; overflow: hidden; box-shadow: inset 0 0 0 1px #203b3426 }
|
||||
.palette-preset:hover { border-color: var(--accent); background: #f5f8f1 }
|
||||
.palette-presets .field-help { margin-top: 10px }
|
||||
.swatch-list { list-style: none; margin: 18px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px }
|
||||
.swatch-card { position: relative; display: grid; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.swatch-card-new { border-style: dashed }
|
||||
/* Der Farbwaehler ist die Flaeche selbst: nichts liegt dazwischen. */
|
||||
.swatch-well { position: relative; display: grid; margin: 0; cursor: pointer }
|
||||
.swatch-input { display: block; width: 100%; height: 76px; min-height: 0; padding: 0; border: 0; border-radius: 0; background: none; cursor: pointer }
|
||||
.swatch-input::-webkit-color-swatch-wrapper { padding: 0 }
|
||||
.swatch-input::-webkit-color-swatch { border: 0; border-radius: 0 }
|
||||
.swatch-input::-moz-color-swatch { border: 0 }
|
||||
.swatch-hex { position: absolute; left: 10px; bottom: 8px; padding: 2px 7px; border-radius: 5px; background: #ffffffe6; color: var(--ink); font-size: 11px; box-shadow: 0 1px 2px #0000001f }
|
||||
.swatch-fields { display: grid; padding: 0 12px 12px }
|
||||
.swatch-fields label { margin: 10px 0 4px; font-size: 11px }
|
||||
.swatch-fields input { min-height: 36px; padding: 6px 10px; font-size: 13px }
|
||||
.swatch-card .field-remove { position: absolute; top: 6px; right: 6px; width: 32px; height: 32px; background: #ffffffe6 }
|
||||
.list-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px }
|
||||
.list-actions .field-help { flex: 1 1 300px; margin: 0 }
|
||||
.font-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 12px }
|
||||
.font-card { position: relative; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.font-card-new { border-style: dashed }
|
||||
/* Die Probe steht in der Schrift selbst - das Skript setzt die Familie, die Datei liegt auf dem Server. */
|
||||
.font-sample { display: flex; align-items: center; gap: 18px; min-height: 88px; padding: 18px 60px 18px 22px; background: #fafbf8; border-bottom: 1px solid var(--line) }
|
||||
.font-sample-big { flex: none; font-size: 44px; line-height: 1; font-weight: 500 }
|
||||
.font-sample-line { display: grid; gap: 6px; min-width: 0 }
|
||||
.font-sample-text { font-size: 19px; line-height: 1.3; overflow-wrap: anywhere }
|
||||
.font-sample-weights { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--muted) }
|
||||
.font-weight-100 { font-weight: 100 } .font-weight-200 { font-weight: 200 } .font-weight-300 { font-weight: 300 }
|
||||
.font-weight-400 { font-weight: 400 } .font-weight-500 { font-weight: 500 } .font-weight-600 { font-weight: 600 }
|
||||
.font-weight-700 { font-weight: 700 } .font-weight-800 { font-weight: 800 } .font-weight-900 { font-weight: 900 }
|
||||
.font-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0 16px; padding: 0 22px 18px }
|
||||
.font-fields label { margin-top: 12px }
|
||||
.font-weights { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 16px 0 0; padding: 0; border: 0 }
|
||||
.font-weights legend { float: left; margin-right: 8px; padding: 0; font-size: 12px; font-weight: 600 }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 5px 10px; border: 1px solid var(--line); border-radius: 20px; background: var(--paper); font-size: 12px; font-weight: 500; cursor: pointer }
|
||||
.chip input { width: 14px; min-height: 14px; margin: 0 }
|
||||
.chip:has(input:checked) { border-color: var(--accent); background: #edf4e8; color: #22553e }
|
||||
.font-card .field-remove { position: absolute; top: 10px; right: 10px }
|
||||
.type-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 380px); gap: 16px 36px }
|
||||
.type-fields { min-width: 0 }
|
||||
.type-colors .color-control { margin-top: 0 }
|
||||
.type-preview-column { min-width: 0 }
|
||||
.type-preview { margin-top: 12px; padding: 26px 26px 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; overflow: hidden }
|
||||
.type-preview p { margin: 0 }
|
||||
.type-preview .type-preview-h1 { margin-bottom: 10px; font-size: 34px; font-weight: 700; line-height: 1.15; letter-spacing: -.5px }
|
||||
.type-preview .type-preview-h2 { margin-bottom: 14px; font-size: 26px; font-weight: 600; line-height: 1.2 }
|
||||
.type-preview-p { font-size: 17px; line-height: 1.6 }
|
||||
.type-preview-link { text-decoration: underline; text-underline-offset: 3px }
|
||||
.theme-save { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.theme-save .muted { font-size: 12px }
|
||||
.theme-form .button-defaults { margin: 0; background: var(--paper) }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
@@ -0,0 +1 @@
|
||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { devices, effectiveStyle, hoverStyle, locate, upgradeDocument, setStyle } from './editor-model.js';
|
||||
|
||||
export function effectiveButton(block, device, defaults) {
|
||||
const result = { ...defaults };
|
||||
for (const name of Object.keys(devices)) {
|
||||
Object.assign(result, block.layout?.button?.[name]);
|
||||
if (name === device) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Change appearance without touching copy, URL, typography, sizing or layout placement. */
|
||||
export function setButtonDesign(document, id, device, values) {
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (block?.type !== 'button' || !Object.hasOwn(devices, device)) return;
|
||||
if (Object.values(values).some(value => value !== '')) upgradeDocument(document, 21);
|
||||
const target = ((block.layout ??= {}).button ??= {});
|
||||
target[device] ??= {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === '') delete target[device][key]; else target[device][key] = value;
|
||||
// A chosen shape/appearance must visibly take effect even on an older hand-styled button.
|
||||
// Other groups (font, width, padding, margins, position) remain untouched. Undo is atomic.
|
||||
const reset = key === 'shape' ? ['borderRadius'] : key === 'appearance' ? ['backgroundColor', 'backgroundImage', 'gradientFrom', 'gradientTo', 'color', 'borderColor', 'borderWidth', 'borderStyle'] : [];
|
||||
for (const property of reset) {
|
||||
setStyle(document, id, device, property, '');
|
||||
setStyle(document, id, device, property, '', { hover: true });
|
||||
}
|
||||
}
|
||||
if (!Object.keys(target[device]).length) delete target[device];
|
||||
if (!Object.keys(target).length) delete block.layout.button;
|
||||
}
|
||||
|
||||
const element = (tag, className, text) => {
|
||||
const node = document.createElement(tag); if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text; return node;
|
||||
};
|
||||
const color = (value, schema) => value?.startsWith('@') ? schema.colors.find(row => row.key === value.slice(1))?.value || '#203b34' : value;
|
||||
|
||||
export function previewVariables(node, values, schema) {
|
||||
const set = (key, value) => node.style.setProperty(`--kb-${key}`, String(value));
|
||||
set('fill', color(values.backgroundColor, schema)); set('ink', color(values.color, schema));
|
||||
set('hover-fill', color(values.hoverBackgroundColor, schema)); set('hover-ink', color(values.hoverColor, schema));
|
||||
const font = schema.fonts.find(font => font.key === values.fontFamily);
|
||||
set('font', font ? `${font.family ? `"${font.family}",` : ''}${font.fallback}` : 'inherit');
|
||||
set('font-size', `${values.fontSize}px`); set('weight', values.fontWeight);
|
||||
set('tracking', `${values.letterSpacing}px`); set('transform', values.textTransform);
|
||||
}
|
||||
|
||||
export function buttonSample(text, values, schema, hover = false) {
|
||||
const sample = element('span', `cms-button b-desktop-shape-${values.shape} b-desktop-appearance-${values.appearance} b-desktop-size-${values.size}${hover ? ' is-hover' : ''}`, text || 'Button');
|
||||
previewVariables(sample, values, schema);
|
||||
return sample;
|
||||
}
|
||||
|
||||
export function renderButtonControls(panel, { block, device, schema, apply }) {
|
||||
const group = element('section', 'button-designer'); group.setAttribute('aria-label', 'Button-Gestaltung');
|
||||
group.append(element('h3', null, 'Button-Gestaltung'));
|
||||
const current = effectiveButton(block, device, schema.buttons);
|
||||
const previews = element('div', 'button-state-previews');
|
||||
for (const [label, hover] of [['Normal', false], ['Beim Überfahren', true]]) {
|
||||
const state = element('div', 'button-state'); state.append(element('span', 'field-help', label));
|
||||
const sample = buttonSample(block.props.text, current, schema, hover);
|
||||
// Preview the block's own free styling as well as its variant and site defaults.
|
||||
for (const [key, definition] of Object.entries(schema.properties)) {
|
||||
if (!definition.css || definition.target || definition.sides || definition.corners || ['position','display','width','height','minWidth','maxWidth','minHeight','maxHeight'].includes(key)) continue;
|
||||
let value = effectiveStyle(block, device, key).value;
|
||||
if (hover) for (const name of Object.keys(devices)) { value = hoverStyle(block, name, key) ?? value; if (name === device) break; }
|
||||
if (value !== undefined && value !== '') sample.style.setProperty(definition.css, definition.type === 'color' ? color(value, schema) : String(value));
|
||||
}
|
||||
state.append(sample); previews.append(state);
|
||||
}
|
||||
group.append(previews);
|
||||
const cards = element('div', 'button-preset-grid'); cards.setAttribute('role', 'group'); cards.setAttribute('aria-label', 'Button-Vorlagen');
|
||||
for (const [index, preset] of schema.buttonPresets.entries()) {
|
||||
const choice = element('button', 'button-preset'); choice.type = 'button'; choice.id = `button-preset-${index}`;
|
||||
choice.setAttribute('aria-label', `Button-Vorlage ${preset.name}`);
|
||||
choice.setAttribute('aria-pressed', String(current.shape === preset.shape && current.appearance === preset.appearance));
|
||||
const sample = buttonSample('Button', { ...schema.buttons, ...preset, size: 'small' }, schema); sample.setAttribute('aria-hidden', 'true');
|
||||
choice.append(sample, element('span', 'button-preset-label', preset.name));
|
||||
choice.addEventListener('click', () => apply({ shape: preset.shape, appearance: preset.appearance }, choice.id)); cards.append(choice);
|
||||
}
|
||||
group.append(cards);
|
||||
for (const [key, options] of Object.entries(schema.buttonOptions)) {
|
||||
const id = `button-${key}`, label = element('label', null, { shape: 'Form', appearance: 'Darstellung', size: 'Button-Größe' }[key]); label.htmlFor = id;
|
||||
const select = element('select'); select.id = id;
|
||||
select.append(new Option(`Übernehmen: ${options[current[key]]}`, ''));
|
||||
for (const [value, name] of Object.entries(options)) select.append(new Option(name, value));
|
||||
select.value = block.layout?.button?.[device]?.[key] ?? '';
|
||||
select.addEventListener('change', () => apply({ [key]: select.value }, id)); group.append(label, select);
|
||||
}
|
||||
const reset = element('button', 'secondary full-width', device === 'desktop' ? 'Website-Standard übernehmen' : 'Größeres Gerät übernehmen'); reset.type = 'button'; reset.id = 'button-reset';
|
||||
reset.addEventListener('click', () => apply({ shape: '', appearance: '', size: '' }, reset.id));
|
||||
group.append(reset, element('p', 'field-help', `${devices[device]}: Form und Darstellung übernehmen die Website-Vorgaben, solange kein eigener Wert gesetzt ist. Vorlagen ersetzen eigene Füllung und Rahmen; Schrift, Breite und Abstände bleiben erhalten.`));
|
||||
panel.append(group);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Shared by the public renderer and the actual button samples in the editor. */
|
||||
.cms-button{display:inline-flex;align-items:center;justify-content:center;gap:.65em;max-width:100%;min-height:44px;padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));border:2px solid var(--b-border,var(--kb-border,transparent));border-radius:var(--b-radius,var(--kb-radius,8px));background:var(--b-bg,var(--kb-bg,var(--kb-fill,var(--ink,#203b34))));color:var(--b-fg,var(--kb-fg,var(--kb-ink,var(--paper,#f6f7f4))));font-family:var(--kb-font,inherit);font-size:var(--kb-font-size,14px);font-weight:var(--kb-weight,600);letter-spacing:var(--kb-tracking,0);text-transform:var(--kb-transform,none);line-height:1.6;text-align:center;text-decoration:none;overflow-wrap:anywhere;box-sizing:border-box}
|
||||
.cms-button:hover,.cms-button:focus-visible,.cms-button.is-hover{background:var(--b-hover-bg,var(--kb-hover-bg,var(--kb-hover-fill,var(--ink,#203b34))));color:var(--b-hover-fg,var(--kb-hover-fg,var(--kb-hover-ink,var(--paper,#f6f7f4))));text-decoration:none}
|
||||
.cms-button:focus-visible{outline:3px solid currentColor;outline-offset:4px}
|
||||
.cms-button svg{width:1.15em;height:1.15em;flex:none}
|
||||
.cms-button:where(.b-desktop-shape-square){--b-radius:0px}.cms-button:where(.b-desktop-shape-rounded){--b-radius:8px}.cms-button:where(.b-desktop-shape-pill){--b-radius:999px}
|
||||
.cms-button:where(.b-desktop-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-desktop-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-desktop-size-large){--b-py:19px;--b-px:30px}
|
||||
.cms-button:where(.b-desktop-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
|
||||
.cms-button:where(.b-desktop-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-desktop-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-desktop-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
|
||||
/* Responsive variants */
|
||||
@media(max-width:1024px){
|
||||
.cms-button:where(.b-tablet-shape-square){--b-radius:0px}.cms-button:where(.b-tablet-shape-rounded){--b-radius:8px}.cms-button:where(.b-tablet-shape-pill){--b-radius:999px}
|
||||
.cms-button:where(.b-tablet-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-tablet-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-tablet-size-large){--b-py:19px;--b-px:30px}
|
||||
.cms-button:where(.b-tablet-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
|
||||
.cms-button:where(.b-tablet-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-tablet-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-tablet-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
|
||||
}
|
||||
@media(max-width:767px){
|
||||
.cms-button:where(.b-mobile-shape-square){--b-radius:0px}.cms-button:where(.b-mobile-shape-rounded){--b-radius:8px}.cms-button:where(.b-mobile-shape-pill){--b-radius:999px}
|
||||
.cms-button:where(.b-mobile-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-mobile-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-mobile-size-large){--b-py:19px;--b-px:30px}
|
||||
.cms-button:where(.b-mobile-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
|
||||
.cms-button:where(.b-mobile-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-mobile-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-mobile-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden}
|
||||
.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-trigger:hover{background:#f5f7f3}
|
||||
.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
|
||||
.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px}
|
||||
.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit}
|
||||
.color-description{display:grid;gap:3px;flex:1;min-width:0}
|
||||
.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
|
||||
.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere}
|
||||
.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c}
|
||||
.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)}
|
||||
.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8}
|
||||
.color-choices[hidden]{display:none}
|
||||
.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258}
|
||||
.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin}
|
||||
.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer}
|
||||
.color-option:hover{background:#eef3ea;border-color:#718575}
|
||||
.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8}
|
||||
.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426}
|
||||
.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.color-custom{display:flex;align-items:center;gap:6px}
|
||||
.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff}
|
||||
.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px}
|
||||
.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0}
|
||||
.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer}
|
||||
.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none}
|
||||
@@ -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';
|
||||
|
||||
@@ -18,6 +18,8 @@ export const GROUPS = {
|
||||
image: 'Bild',
|
||||
layout: 'Layout',
|
||||
effect: 'Wirkung',
|
||||
menu: 'Menü-Knopf',
|
||||
slider: 'Slider-Steuerung',
|
||||
};
|
||||
|
||||
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
|
||||
@@ -104,7 +106,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 +151,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 +556,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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
@@ -10,7 +10,7 @@ export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H',
|
||||
export const CATEGORIES = [
|
||||
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'logos', 'progress'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
|
||||
];
|
||||
@@ -107,6 +107,8 @@ export function createBlock(type) {
|
||||
if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 };
|
||||
if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' };
|
||||
if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true };
|
||||
// Welche Profile es gibt, steht in den Einstellungen; der Block bringt nur die Darstellung mit.
|
||||
if (type === 'social') block.props = { size: 24, shape: 'plain', layout: 'horizontal', labels: false, networks: [], newTab: true };
|
||||
if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true, autoplay: 0 };
|
||||
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||
return block;
|
||||
@@ -125,6 +127,7 @@ export function upgradeForType(document, type) {
|
||||
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'embed') upgradeDocument(document, 14);
|
||||
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
|
||||
if (type === 'social') upgradeDocument(document, 23);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,6 +174,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 +258,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 = 23;
|
||||
|
||||
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 +510,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 +526,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,10 +568,12 @@ 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 === 'slider') return block.type === 'slider';
|
||||
if (property.group === 'menu') return block.type === 'menu';
|
||||
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);
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact'].includes(block.type);
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
|
||||
return true;
|
||||
}
|
||||
+184
-222
@@ -1,12 +1,18 @@
|
||||
/* 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. */
|
||||
/* Die Farbe fuer "hier steht ein eigener Wert". Dunkler als der Fokusring, damit weisse
|
||||
Schrift darauf die Kontrastschwelle sicher nimmt (5,6:1). */
|
||||
:root{--used:#9d4e0d}
|
||||
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,147 +36,169 @@ 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}
|
||||
.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0}
|
||||
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||
/* Belegte Zeilen tragen dieselbe Farbe wie die belegten Geraetechips - eine Farbe, eine
|
||||
Bedeutung: hier steht ein eigener Wert und nicht der geerbte. */
|
||||
.design-own .design-row-head label{color:var(--used);font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--used)}
|
||||
.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,25 +215,31 @@ 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.
|
||||
|
||||
/* 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}
|
||||
.device-chip:hover{border-color:var(--accent)}
|
||||
.device-chip-own{background:#eef5e6;border-color:var(--accent);color:var(--accent)}
|
||||
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
|
||||
Zwei Zustaende, zwei Mittel. "Belegt" - dieses Geraet traegt einen eigenen Wert - ist die
|
||||
Auskunft, die man ueber die ganze Liste hinweg sucht, und bekommt deshalb die kraeftige Farbe.
|
||||
"Gerade bearbeitet" ist ein Rahmen, und zwar ein Rahmen INNEN: ein `outline` liegt ausserhalb
|
||||
des Kaestchens, laeuft bei 2 px Abstand unter den Nachbarn und wird von der seitlich
|
||||
beschnittenen Spalte gekappt - es sah aus wie ein abgeschnittener roter Kasten. */
|
||||
.device-chips{display:flex;flex:none;gap:6px;margin-left:auto}
|
||||
.device-chip{width:22px;height:22px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:5px;background:#fff;color:var(--muted);font:700 10px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.device-chip:hover{border-color:var(--ink)}
|
||||
.device-chip-own{background:var(--used);border-color:var(--used);color:#fff}
|
||||
.device-chip[aria-pressed=true]{border-width:2px;border-color:var(--ink)}
|
||||
.device-chip-own[aria-pressed=true]{border-color:var(--ink);box-shadow:inset 0 0 0 1px #fff}
|
||||
/* Der Fokusring bleibt innerhalb der Luecke zwischen zwei Chips - nichts liegt uebereinander. */
|
||||
.device-chip:focus-visible{outline-width:2px;outline-offset:0}
|
||||
.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 +252,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 +263,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 +279,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 +293,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))}
|
||||
}
|
||||
+278
-92
@@ -1,16 +1,20 @@
|
||||
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'];
|
||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
|
||||
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||||
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
|
||||
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
|
||||
|
||||
const root = document.querySelector('#editor');
|
||||
const $ = selector => document.querySelector(selector);
|
||||
@@ -30,12 +34,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 +118,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 +161,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 +192,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 +224,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 +243,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 +294,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 +308,8 @@ 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 (key === 'faq') upgradeDocument(doc, 23);
|
||||
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 +346,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 +390,8 @@ 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('Menü-Knopf', 'burgerLabel', block.props.burgerLabel || 'both', { choices: { both: 'Symbol und Text', text: 'Nur Text', icon: 'Nur Symbol' }, help: 'Wie der aufklappbare Knopf beschriftet ist. „Nur Symbol“ behält den Namen für Vorlesesoftware. Farben, Rahmen und Schriftgröße des Knopfes stehen unter Design → Menü-Knopf.' });
|
||||
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') {
|
||||
@@ -301,6 +402,33 @@ function renderProperties() {
|
||||
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
}
|
||||
if (block.type === 'social') {
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
|
||||
field('Form', 'shape', block.props.shape, { choices: { plain: 'Nur Symbol', circle: 'Runde Fläche', square: 'Eckige Fläche' } });
|
||||
field('Anordnung', 'layout', block.props.layout, { choices: { horizontal: 'Nebeneinander', vertical: 'Untereinander' } });
|
||||
field('Namen anzeigen', 'labels', block.props.labels === true, { checkbox: true, help: 'Ohne Häkchen tragen die Links ihren Namen nur für Screenreader.' });
|
||||
field('In neuem Tab öffnen', 'newTab', block.props.newTab !== false, { checkbox: true });
|
||||
// Eine Auswahl aus den eingetragenen Profilen; keins gewählt heißt alle.
|
||||
const chosen = new Set(block.props.networks ?? []);
|
||||
const group = document.createElement('fieldset'); group.className = 'field-set';
|
||||
const legend = document.createElement('legend'); legend.textContent = 'Nur diese Netzwerke'; group.append(legend);
|
||||
for (const [key, name] of Object.entries(SOCIAL_NETWORKS)) {
|
||||
const label = document.createElement('label'); label.className = 'check-label';
|
||||
const box = document.createElement('input'); box.type = 'checkbox'; box.checked = chosen.has(key);
|
||||
box.id = `social-${block.id}-${key}`; label.htmlFor = box.id;
|
||||
box.addEventListener('change', () => {
|
||||
history.change(doc => {
|
||||
const current = locate(doc.blocks, block.id)?.block; if (!current) return;
|
||||
const set = new Set(current.props.networks ?? []); box.checked ? set.add(key) : set.delete(key);
|
||||
current.props.networks = Object.keys(SOCIAL_NETWORKS).filter(network => set.has(network));
|
||||
});
|
||||
renderTree(); controls(); schedulePreview();
|
||||
});
|
||||
label.append(box, ` ${name}`); group.append(label);
|
||||
}
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Ohne Auswahl erscheinen alle eingetragenen Profile.';
|
||||
group.append(note); panel.append(group);
|
||||
}
|
||||
if (block.type === 'progress') {
|
||||
field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Wert (%)', 'value', block.props.value, { number: true, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
|
||||
@@ -325,6 +453,7 @@ function renderProperties() {
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' });
|
||||
const hint = document.createElement('p'); hint.className = 'field-help';
|
||||
hint.textContent = 'Wähle einen Eintrag, um seinen Titel und Inhalt zu bearbeiten. Texte, Bilder und weitere Elemente gehören in den jeweiligen Eintrag.';
|
||||
const entries = document.createElement('div'); entries.className = 'accordion-entry-list';
|
||||
@@ -339,6 +468,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 +629,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 +867,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 +899,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 +926,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 +966,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 +984,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 +1029,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 +1069,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 +1112,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 +1141,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 +1189,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 +1214,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 +1273,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 +1379,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 +1429,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();
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Der Formulareditor in der Verwaltung.
|
||||
*
|
||||
* Ohne Skript ist die Seite vollständig bedienbar: Felder sind Karten mit Eingaben, ein Feld ohne
|
||||
* Beschriftung wird nicht gespeichert, die Vorschau zeigt den gespeicherten Stand. Das Skript
|
||||
* macht daraus einen Editor: Felder hinzufügen und entfernen, den technischen Namen aus der
|
||||
* Beschriftung ableiten, Optionen nur bei einer Auswahl zeigen, Farben aus der Palette wählen -
|
||||
* und eine Vorschau, die jeder Eingabe folgt, mit dem Standardbutton der Website.
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
import { buttonSample } from './button-design.js';
|
||||
|
||||
const root = document.querySelector('[data-form-schema]');
|
||||
if (root) {
|
||||
const schema = JSON.parse(root.dataset.formSchema);
|
||||
const defaults = {
|
||||
fieldBorderColor: schema.base.color,
|
||||
fieldBackgroundColor: '#ffffff',
|
||||
buttonColor: schema.buttons.backgroundColor,
|
||||
buttonInk: schema.buttons.color,
|
||||
};
|
||||
const hex = value => /^#[0-9a-f]{6}/i.test(value || '') ? value.slice(0, 7) : schema.colors.find(color => `@${color.key}` === value)?.value || '';
|
||||
// Dunkler beim Überfahren, so wie es die Seite selbst tut (82 % Farbe, Rest Schwarz).
|
||||
const darker = value => {
|
||||
const rgb = hex(value); if (!rgb) return value;
|
||||
return '#' + [1, 3, 5].map(index => Math.round(parseInt(rgb.slice(index, index + 2), 16) * 0.82).toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
const luminance = rgb => {
|
||||
const [r, g, b] = [1, 3, 5].map(index => parseInt(rgb.slice(index, index + 2), 16) / 255)
|
||||
.map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const contrast = (back, front) => {
|
||||
if (!back || !front) return null;
|
||||
const [light, dark] = [luminance(back), luminance(front)].sort((a, b) => b - a);
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
};
|
||||
const slug = text => text.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 32);
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag); node.className = className;
|
||||
if (text !== undefined) node.textContent = text; return node;
|
||||
};
|
||||
|
||||
for (const form of root.querySelectorAll('[data-form-editor]')) {
|
||||
const list = form.querySelector('[data-field-list]'), template = form.querySelector('[data-field-template]');
|
||||
const preview = form.querySelector('[data-form-preview]'), add = form.querySelector('[data-add-field]');
|
||||
const buttonLabel = form.querySelector('[data-form-button-label]');
|
||||
const colorInputs = {};
|
||||
const cards = () => [...list.querySelectorAll('[data-field]')];
|
||||
let next = cards().length;
|
||||
|
||||
const renderPreview = () => {
|
||||
preview.replaceChildren();
|
||||
preview.style.color = hex(schema.base.color) || '#203b34';
|
||||
preview.style.background = hex(schema.base.backgroundColor) || '#ffffff';
|
||||
preview.style.setProperty('--form-border', hex(colorInputs.fieldBorderColor.value) || 'currentColor');
|
||||
preview.style.setProperty('--form-bg', hex(colorInputs.fieldBackgroundColor.value) || '#ffffff');
|
||||
let shown = 0;
|
||||
for (const card of cards()) {
|
||||
const label = card.querySelector('[data-field-label]').value.trim();
|
||||
if (!label) continue;
|
||||
shown++;
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const required = type === 'consent' || card.querySelector('[data-field-required]').checked;
|
||||
const field = el('div', 'form-preview-field');
|
||||
if (type === 'consent') {
|
||||
const check = el('span', 'form-preview-check');
|
||||
check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`));
|
||||
field.append(check);
|
||||
} else {
|
||||
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
|
||||
field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}`, type === 'select' ? '— bitte wählen —' : ''));
|
||||
}
|
||||
const help = card.querySelector('[data-field-help]').value.trim();
|
||||
if (help) field.append(el('span', 'form-preview-help', help));
|
||||
preview.append(field);
|
||||
}
|
||||
if (!shown) preview.append(el('p', 'form-preview-empty', 'Noch kein Feld mit Beschriftung.'));
|
||||
const values = { ...schema.buttons };
|
||||
const fill = colorInputs.buttonColor.value, ink = colorInputs.buttonInk.value;
|
||||
if (fill) { values.backgroundColor = fill; values.hoverBackgroundColor = darker(fill); }
|
||||
if (ink) { values.color = ink; values.hoverColor = ink; }
|
||||
preview.append(buttonSample(buttonLabel.value.trim() || 'Absenden', values, schema));
|
||||
// Der Kontrast der Buttonschrift auf der Buttonfarbe, nach WCAG: unter 4,5:1 ist der Text
|
||||
// fuer viele nicht mehr lesbar - das soll man sehen, bevor es auf der Website steht.
|
||||
const ratio = contrast(hex(values.backgroundColor), hex(values.color));
|
||||
note.hidden = ratio === null;
|
||||
if (ratio !== null) {
|
||||
note.className = `contrast-note ${ratio >= 4.5 ? 'contrast-ok' : ratio >= 3 ? 'contrast-warn' : 'contrast-bad'}`;
|
||||
note.textContent = `Buttonschrift auf Buttonfarbe: Kontrast ${ratio.toFixed(1).replace('.', ',')}:1 – ${ratio >= 4.5 ? 'gut lesbar.' : ratio >= 3 ? 'nur für große Schrift ausreichend, für den Button zu wenig.' : 'zu wenig, der Text ist kaum lesbar.'}`;
|
||||
}
|
||||
};
|
||||
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
|
||||
preview.after(note);
|
||||
|
||||
const refresh = () => {
|
||||
cards().forEach((card, index) => {
|
||||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
card.querySelector('[data-field-options]').hidden = type !== 'select';
|
||||
const remove = card.querySelector('[data-remove-field]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`);
|
||||
const required = card.querySelector('[data-field-required]');
|
||||
// Eine Einwilligung ist immer Pflicht - das Kästchen sagt es, statt es zu verstecken.
|
||||
if (type === 'consent') { required.checked = true; required.disabled = true; } else required.disabled = false;
|
||||
});
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
// Die leere Karte für ein neues Feld braucht es nur, solange es kein Feld gibt: mit Skript
|
||||
// kommt jedes weitere über den Knopf.
|
||||
const empty = cards().filter(card => card.classList.contains('field-card-new'));
|
||||
if (cards().length > empty.length) empty.forEach(card => card.remove());
|
||||
add.hidden = false;
|
||||
add.addEventListener('click', () => {
|
||||
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
|
||||
const card = list.lastElementChild; card.classList.remove('field-card-new');
|
||||
refresh(); card.querySelector('[data-field-label]').focus();
|
||||
});
|
||||
list.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-field]');
|
||||
if (!button) return;
|
||||
button.closest('[data-field]').remove(); refresh(); add.focus();
|
||||
});
|
||||
list.addEventListener('input', event => {
|
||||
const card = event.target.closest('[data-field]');
|
||||
if (event.target.matches('[data-field-label]')) {
|
||||
// Der technische Name folgt der Beschriftung, bis jemand ihn selbst schreibt.
|
||||
const name = card.querySelector('[data-field-name]');
|
||||
if (!name.dataset.touched) name.value = slug(event.target.value);
|
||||
}
|
||||
if (event.target.matches('[data-field-name]')) event.target.dataset.touched = event.target.value ? '1' : '';
|
||||
refresh();
|
||||
});
|
||||
list.addEventListener('change', refresh);
|
||||
for (const card of cards()) {
|
||||
const name = card.querySelector('[data-field-name]');
|
||||
if (name.value) name.dataset.touched = '1';
|
||||
}
|
||||
|
||||
for (const input of form.querySelectorAll('[data-form-color]')) {
|
||||
const key = input.dataset.formColor, label = form.querySelector(`label[for="${input.id}"]`).textContent;
|
||||
const id = input.id; input.type = 'hidden'; input.id += '-value';
|
||||
colorInputs[key] = input;
|
||||
const control = createColorControl({
|
||||
id, label, value: input.value, inheritedValue: defaults[key], colors: schema.colors,
|
||||
onChange: value => { input.value = value; renderPreview(); }, onComplete: () => {},
|
||||
});
|
||||
control.querySelector('.color-inherit').textContent = 'Wie die Website';
|
||||
input.after(control);
|
||||
}
|
||||
buttonLabel.addEventListener('input', renderPreview);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export const PREVIEWS = {
|
||||
contact: '<rect x="2.5" y="3.5" width="19" height="9" rx="1.5"/><path d="M2.9 4.4l9.1 5 9.1-5"/>',
|
||||
logos: [2.5, 8, 13.5, 19].map(x => `<rect x="${x}" y="5.5" width="4.5" height="5" rx="1.2"/>`).join(''),
|
||||
progress: '<rect x="2" y="6" width="20" height="4" rx="2"/><rect x="2" y="6" width="12" height="4" rx="2" fill="currentColor" stroke="none"/>',
|
||||
social: [4.5, 12, 19.5].map(x => `<circle cx="${x}" cy="8" r="3"/>`).join('') + '<path d="M3.6 8h1.8M11.1 8h1.8M18.6 8h1.8"/>',
|
||||
|
||||
// Gruppen
|
||||
gallery: [2.5, 12.5].flatMap(x => [2.5, 8.5].map(y => `<rect x="${x}" y="${y}" width="9" height="5" rx="1.2"/>`)).join(''),
|
||||
|
||||
@@ -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', people: 'Menschen', music: 'Musik', home: 'Haus' };
|
||||
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
@@ -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();
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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;
|
||||
const root = doc.documentElement;
|
||||
// Der Nach-oben-Knopf blendete sich ueber eine Scroll-Animation ein. Browser, die
|
||||
// `animation-timeline: scroll()` zwar kennen, aber nicht ausfuehren, liessen ihn im ersten
|
||||
// Bild der Animation stehen - unsichtbar, dauerhaft. Deshalb entscheidet jetzt eine Klasse.
|
||||
// Ohne dieses Skript bleibt der Knopf sichtbar: erreichbar ist besser als unsichtbar.
|
||||
const after = parseInt(getComputedStyle(root).getPropertyValue('--kant-top-after'), 10);
|
||||
const schwelle = Number.isFinite(after) && after > 0 ? after : 400;
|
||||
let queued = false;
|
||||
const sync = () => {
|
||||
queued = false;
|
||||
root.classList.toggle('is-scrolled', view.scrollY > 40);
|
||||
root.classList.toggle('kant-top-shown', view.scrollY > schwelle);
|
||||
};
|
||||
const onScroll = () => { if (!queued) { queued = true; view.requestAnimationFrame(sync); } };
|
||||
view.addEventListener('scroll', onScroll, { passive: true });
|
||||
root.classList.add('kant-top-armed');
|
||||
sync();
|
||||
return () => {
|
||||
view.removeEventListener('scroll', onScroll);
|
||||
root.classList.remove('is-scrolled', 'kant-top-armed', 'kant-top-shown');
|
||||
};
|
||||
}
|
||||
|
||||
if (!document.querySelector('#editor')) initScrolled();
|
||||
+142
-31
@@ -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,9 +95,10 @@ 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}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid var(--form-border,currentColor);border-radius:8px;background:var(--form-bg,#fff);color:inherit;font:inherit;min-height:46px}
|
||||
.form-field textarea{min-height:140px;line-height:1.6;resize:vertical}
|
||||
.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:#8b2f22}
|
||||
.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22}
|
||||
@@ -117,31 +126,56 @@ p{hyphens:auto}
|
||||
|
||||
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
|
||||
stays readable while the decision is open, because a wall would make "accept" the only way
|
||||
forward. Accepting and refusing are the same button in the same row. */
|
||||
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px color-mix(in srgb,var(--ink) 15%,transparent);font-size:14px;line-height:1.6}
|
||||
.kant .consent-bar h2{font-size:16px;margin:0 0 8px}
|
||||
.consent-bar p{margin:0 0 14px}
|
||||
forward. Accepting and refusing are the same button in the same row.
|
||||
|
||||
Aussehen aus den Einstellungen: `--kant-consent-*` im Kopf der Seite (Consent::styles), die
|
||||
Anordnung als Klasse. Ohne Einstellung gilt Papier, Tinte und Akzent des Themes. Ein Wert wie
|
||||
`--k-consent` aus der Palette bleibt als Zwischenstufe erhalten. */
|
||||
.consent-bar{position:fixed;inset-inline:16px;bottom:16px;z-index:50;max-width:640px;max-height:calc(100dvh - 24px);overflow:auto;margin-inline:auto;background:var(--kant-consent-bg,var(--paper));color:var(--kant-consent-ink,var(--ink));border:1px solid color-mix(in srgb,currentColor 12%,transparent);border-radius:var(--kant-consent-radius,18px);padding:22px 24px 20px;box-shadow:0 24px 60px color-mix(in srgb,var(--ink) 22%,transparent);font-size:14px;line-height:1.6;overscroll-behavior:contain}
|
||||
/* Ein schmaler Streifen in der Akzentfarbe oben: die Fläche gehört sichtbar zur Website. */
|
||||
.consent-bar::before{content:'';position:absolute;inset:0 0 auto 0;height:4px;background:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
.kant .consent-bar h2{display:flex;align-items:center;gap:10px;font-size:17px;line-height:1.3;margin:0 0 8px}
|
||||
.consent-icon{flex:none;color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
.consent-bar p{margin:0 0 16px;color:color-mix(in srgb,currentColor 82%,transparent)}
|
||||
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid var(--k-consent,var(--accent));background:var(--k-consent,var(--accent));color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
||||
.consent-actions .consent-button:last-child{background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;flex:1 1 150px;min-height:46px;padding:11px 18px;border-radius:var(--kant-consent-button-radius,10px);border:1.5px solid var(--kant-consent-accent,var(--k-consent,var(--accent)));background:var(--kant-consent-accent,var(--k-consent,var(--accent)));color:var(--kant-consent-accent-ink,#fff);font:inherit;font-weight:600;cursor:pointer;transition:filter .12s,background-color .12s}
|
||||
.consent-decline,.consent-choice{background:transparent;color:inherit;border-color:color-mix(in srgb,currentColor 28%,transparent)}
|
||||
.consent-button:hover{filter:brightness(.94)}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
||||
.consent-details{margin-top:14px}
|
||||
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:4px 0}
|
||||
.consent-decline:hover,.consent-choice:hover{filter:none;background:color-mix(in srgb,currentColor 7%,transparent)}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--kant-consent-accent,var(--k-consent,var(--accent)));outline-offset:2px}
|
||||
/* Fusszeile der Frage: Einzelwahl links, Rechtslinks rechts, auf einer Linie. Aufgeklappt
|
||||
nimmt die Einzelwahl die volle Breite und die Links rutschen rechtsbuendig darunter. */
|
||||
.consent-foot{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:8px 20px;margin-top:14px}
|
||||
.consent-details{flex:0 1 auto;min-width:0}
|
||||
.consent-details[open]{flex-basis:100%}
|
||||
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:6px 0;list-style:none;display:inline-flex;align-items:center;gap:6px}
|
||||
.consent-details>summary::-webkit-details-marker{display:none}
|
||||
.consent-details>summary::after{content:'';width:7px;height:7px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;transform:translateY(-2px) rotate(45deg);transition:transform .15s}
|
||||
.consent-details[open]>summary::after{transform:translateY(2px) rotate(-135deg)}
|
||||
.consent-details>summary:hover{text-decoration:underline}
|
||||
.consent-groups{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:10px}
|
||||
.consent-groups label{display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.5}
|
||||
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0}
|
||||
.consent-choice{margin-top:14px;background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-links{margin:14px 0 0;font-size:12px}
|
||||
.consent-links a{color:var(--accent)}
|
||||
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0;accent-color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
.consent-choice{margin-top:14px;flex:none;width:auto}
|
||||
/* Eigener Selektor mit Element, sonst gewinnt `.consent-bar p` und nimmt den Rand rechts weg. */
|
||||
.consent-bar p.consent-links{margin:0 0 0 auto;display:flex;gap:14px;font-size:12px;color:color-mix(in srgb,currentColor 72%,transparent)}
|
||||
.consent-links a{color:inherit;text-decoration:underline;text-underline-offset:2px}
|
||||
.consent-links a:hover{color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
/* Leiste: über die ganze Breite am unteren Rand, auf breiten Bildschirmen Text links und Knöpfe
|
||||
rechts. Ecke: eine kleine Karte rechts unten, die den Inhalt kaum verdeckt. */
|
||||
.consent-layout-leiste{inset-inline:0;bottom:0;max-width:none;border-radius:var(--kant-consent-radius,18px) var(--kant-consent-radius,18px) 0 0;border-inline:0;border-bottom:0;padding-inline:max(24px,calc((100% - 1180px) / 2))}
|
||||
@media (min-width:900px){.consent-layout-leiste{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 40px;align-items:center}.consent-layout-leiste .consent-body p{margin-bottom:0}.consent-layout-leiste .consent-form{width:360px;max-width:100%}}
|
||||
.consent-layout-ecke{inset-inline:auto 16px;max-width:400px;margin:0}
|
||||
/* While the question is open the bar is fixed at the bottom, so the page has to end above it -
|
||||
otherwise it covers the last element, and an embed's own consent button sat right underneath.
|
||||
Found by the browser test, which could not click it. */
|
||||
body.consent-open{padding-bottom:260px}
|
||||
Found by the browser test, which could not click it. Auf schmalen Bildschirmen ist die Leiste
|
||||
mit Standardtext bis 290 px hoch; 320 px lassen ihr Luft, ohne einen Bildschirm zu verschenken. */
|
||||
body.consent-open{padding-bottom:320px}
|
||||
@media (min-width:621px){body.consent-open{padding-bottom:270px}}
|
||||
@media (min-width:900px){body.consent-open{padding-bottom:200px}}
|
||||
.consent-page main{max-width:680px;margin-inline:auto;padding:40px 20px}
|
||||
.consent-page .consent-bar{position:static;inset:auto;box-shadow:none;max-width:none;margin-top:24px}
|
||||
@media (max-width:620px){.consent-bar{inset-inline:0;bottom:0;border-radius:14px 14px 0 0;padding:16px}.consent-actions .consent-button{flex:1 1 140px}}
|
||||
.consent-page .consent-bar{position:static;inset:auto;box-shadow:none;max-width:none;max-height:none;margin:24px 0 0;display:block}
|
||||
@media (max-width:620px){.consent-bar,.consent-layout-ecke{inset-inline:0;bottom:0;max-width:none;border-radius:var(--kant-consent-radius,18px) var(--kant-consent-radius,18px) 0 0;border-inline:0;border-bottom:0;padding:18px 18px 16px}.consent-layout-leiste{padding-inline:18px}}
|
||||
|
||||
/* An embedded third party, and what stands in its place until it may load. */
|
||||
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 8%,var(--paper))}
|
||||
@@ -169,6 +203,9 @@ body.consent-open{padding-bottom:260px}
|
||||
/* A single icon, sized by class because inline styles are forbidden by the policy. */
|
||||
.block-icon{display:inline-flex;align-items:center;justify-content:center;color:var(--accent);line-height:0}
|
||||
.block-icon svg{width:100%;height:100%;display:block}
|
||||
/* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die
|
||||
Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */
|
||||
.block-icon .block-icon{color:inherit}
|
||||
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
|
||||
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
|
||||
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
|
||||
@@ -192,10 +229,34 @@ body.consent-open{padding-bottom:260px}
|
||||
.slider-slide{min-width:0}
|
||||
.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}/* Mit Punkten braucht es die Rollleiste nicht mehr - sie stand quer unter dem Bild. */
|
||||
.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}}
|
||||
@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,var(--slider-dot,currentColor) 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--slider-dot,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}
|
||||
@media(max-width:650px){.slider-controls button{min-height:44px;min-width:44px}}
|
||||
/* Die Steuerung liegt auf dem Bild, wie beim Hero-Slider von kant-it.de: runde Pfeile links und
|
||||
rechts, Punkte unten rechts (der aktive als Strich in der Akzentfarbe), Pause klein unten links,
|
||||
kein Zähler. Farben kommen aus der Gestaltung des Sliders (--slider-*). */
|
||||
.slider-shell{position:relative}
|
||||
.slider-controls{position:absolute;inset:0;margin:0;padding:0;pointer-events:none;font:14px/1 system-ui,sans-serif}
|
||||
.slider-controls button{pointer-events:auto;cursor:pointer;font:inherit;padding:0;border:0;background:transparent;color:inherit}
|
||||
.slider-controls button:focus-visible{outline:3px solid var(--slider-dot-active,var(--accent));outline-offset:3px}
|
||||
.slider-controls .slider-prev,.slider-controls .slider-next{position:absolute;top:50%;transform:translateY(-50%);width:50px;height:50px;border-radius:50%;background:var(--slider-bg,rgba(255,255,255,.9));color:var(--slider-ink,var(--accent));display:flex;align-items:center;justify-content:center;font-size:0;box-shadow:0 6px 18px rgba(0,0,0,.18);transition:background-color .2s,color .2s,transform .2s}
|
||||
.slider-prev{left:24px}.slider-next{right:24px}
|
||||
.slider-prev::before,.slider-next::before{content:'';width:12px;height:12px;border-left:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);margin-left:5px}
|
||||
.slider-next::before{transform:rotate(-135deg);margin-left:-5px}
|
||||
.slider-controls .slider-prev:hover,.slider-controls .slider-next:hover{background:var(--slider-dot-active,var(--accent));color:#fff}
|
||||
.slider-prev:disabled,.slider-next:disabled{opacity:.4;cursor:default}
|
||||
.slider-position{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||
.slider-control-dots{position:absolute;right:70px;bottom:20px;display:flex;gap:3px}
|
||||
.slider-control-dots button{display:flex;align-items:center;justify-content:center;width:24px;height:24px;font-size:0;transition:width .25s}
|
||||
.slider-control-dots button::before{content:'';width:9px;height:9px;border-radius:99px;background:var(--slider-dot,rgba(255,255,255,.55));transition:width .25s,background-color .25s}
|
||||
.slider-control-dots button[aria-pressed=true]{width:40px}
|
||||
.slider-control-dots button[aria-pressed=true]::before{width:28px;background:var(--slider-dot-active,var(--accent))}
|
||||
.slider-control-dots button:hover::before{background:var(--slider-dot-active,var(--accent))}
|
||||
.slider-controls .slider-play{position:absolute;right:24px;bottom:16px;width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.35);color:#fff;font-size:0;display:flex;align-items:center;justify-content:center}
|
||||
.slider-play::before{content:'';width:0;height:0;border-left:9px solid currentColor;border-top:6px solid transparent;border-bottom:6px solid transparent;margin-left:2px}
|
||||
.slider-play[data-playing=true]::before{width:8px;height:11px;border:0;border-left:3px solid currentColor;border-right:3px solid currentColor;margin-left:0}
|
||||
.slider-play:disabled{opacity:.4;cursor:default}
|
||||
.slider-controls[hidden]{display:none}
|
||||
@media(max-width:600px){.slider-controls .slider-prev,.slider-controls .slider-next{display:none}.slider-control-dots{right:54px;bottom:14px}.slider-controls .slider-play{right:14px;bottom:12px}}
|
||||
|
||||
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
|
||||
.slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)}
|
||||
@@ -332,6 +393,38 @@ 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}
|
||||
/* Im Kopfbereich klappt das Menue als Flaeche unter dem Kopf auf, ueber die volle Breite.
|
||||
Inline geoeffnet stand es sonst in der schmalen Spalte neben der Marke und schob den
|
||||
ganzen Kopf auseinander - auf dem Telefon sah es aus, als fehle das Menue. */
|
||||
.site-header{position:relative}
|
||||
/* Gibt der Kopfbereich seine Flaeche selbst vor, malt der klebende Kopf keine zweite dahinter:
|
||||
sonst zeigte eine durchsichtige Sektion Weiss statt Seite, und die Schattenlinie liesse sich
|
||||
weder faerben noch abschalten. Linie und Schatten kommen dann aus Rahmen und Wirkung. */
|
||||
.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}
|
||||
.site-header .menu-burger[open]>summary{border-radius:8px}
|
||||
.site-header .menu-burger-body{position:absolute;left:0;right:0;top:100%;z-index:30;
|
||||
padding:15px max(16px,calc((100% - var(--k-container,1180px)) / 2 + 16px)) 25px;
|
||||
background:var(--paper);box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
|
||||
.site-header .menu-burger-body .menu-list{gap:0}
|
||||
.site-header .menu-burger-body .menu-list a{padding:12px 10px;font-size:16px}
|
||||
/* 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
|
||||
@@ -342,14 +435,26 @@ body.consent-open{padding-bottom:260px}
|
||||
.scroll-top:hover{filter:brightness(1.08)}
|
||||
.scroll-top:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
|
||||
@media (max-width:767px){.scroll-top{bottom:16px;right:16px}.scroll-top-links{left:16px;right:auto}}
|
||||
/* 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}}
|
||||
}
|
||||
/* Solange die Einwilligungsfrage offen ist, bleibt der Knopf da. Frueher verschwand er ganz -
|
||||
und auf dem Telefon bleibt die Frage oft einfach stehen, der Knopf kam dort also nie.
|
||||
Bis 799 px deckt die Leiste die Ecke ab, in der er steht: dort setzt er sich per
|
||||
Ankerpositionierung auf ihre Oberkante, wie hoch ihr Text auch umbricht. Ab 800 px steht die
|
||||
Leiste zentriert mit Rand, die Ecke bleibt frei. Ein Browser ohne Ankerpositionierung blendet
|
||||
ihn in der schmalen Stufe weiter aus - lieber kein Knopf als einer unter der Leiste. Der Anker
|
||||
muss im Dokument vor dem Knopf stehen; page.html.twig gibt die Leiste deshalb zuerst aus. */
|
||||
.consent-bar{anchor-name:--kant-consent}
|
||||
@media (max-width:799px){
|
||||
@supports (anchor-name:--kant-consent){body.consent-open .scroll-top{position-anchor:--kant-consent;bottom:calc(anchor(top) + 12px)}}
|
||||
@supports not (anchor-name:--kant-consent){body.consent-open .scroll-top{display:none}}
|
||||
}
|
||||
/* Leiste und Ecke decken die Ecke auf jeder Breite ab - dort gilt dasselbe ohne Stufe. */
|
||||
@supports (anchor-name:--kant-consent){body.consent-open:has(.consent-layout-leiste,.consent-layout-ecke) .scroll-top{position-anchor:--kant-consent;bottom:calc(anchor(top) + 12px)}}
|
||||
@supports not (anchor-name:--kant-consent){body.consent-open:has(.consent-layout-leiste,.consent-layout-ecke) .scroll-top{display:none}}
|
||||
/* Sichtbar, sobald der Leser ein Stueck gescrollt hat - gesteuert von scrolled.js ueber
|
||||
`kant-top-armed` und `kant-top-shown`. Ohne Skript bleibt der Knopf einfach sichtbar. */
|
||||
html.kant-top-armed .scroll-top{opacity:0;visibility:hidden;transform:translateY(10px);transition:opacity .25s,visibility .25s,transform .25s}
|
||||
html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;transform:none}
|
||||
@media (prefers-reduced-motion: reduce){html.motion-respect .scroll-top{transition: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
|
||||
öffnet der Verweis einfach das große Bild. */
|
||||
@@ -373,3 +478,9 @@ 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}
|
||||
|
||||
/* Soziale Profile: eine Symbolreihe, Farbe und Abstand vom Block, Größe als Klasse. */
|
||||
.block-social{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin:0;padding:0;list-style:none}.block-social.social-vertical{flex-direction:column;align-items:flex-start}.block-social a{display:inline-flex;align-items:center;gap:10px;color:inherit;text-decoration:none;line-height:1;transition:transform .2s,background-color .2s,opacity .2s}.block-social a:hover{transform:translateY(-2px)}.block-social svg{display:block;flex:none}.block-social.social-labels a{font-size:.875em;font-weight:600}.social-shape-circle a,.social-shape-square a{padding:.5em;background:color-mix(in srgb,currentColor 10%,transparent)}.social-shape-circle a{border-radius:999px}.social-shape-square a{border-radius:.35em}.social-shape-circle a:hover,.social-shape-square a:hover{background:color-mix(in srgb,currentColor 18%,transparent)}.social-labels.social-shape-circle a,.social-labels.social-shape-square a{padding-inline:.8em}.social-size-12 svg{width:12px;height:12px}.social-size-16 svg{width:16px;height:16px}.social-size-20 svg{width:20px;height:20px}.social-size-24 svg{width:24px;height:24px}.social-size-32 svg{width:32px;height:32px}.social-size-40 svg{width:40px;height:40px}.social-size-48 svg{width:48px;height:48px}.social-size-56 svg{width:56px;height:56px}.social-size-64 svg{width:64px;height:64px}.social-size-80 svg{width:80px;height:80px}.social-size-96 svg{width:96px;height:96px}.social-size-120 svg{width:120px;height:120px}.social-size-160 svg{width:160px;height:160px}.social-size-200 svg{width:200px;height:200px}.social-size-240 svg{width:240px;height:240px}
|
||||
@media(prefers-reduced-motion:reduce){html.motion-respect .block-social a{transition:none}html.motion-respect .block-social a:hover{transform:none}}
|
||||
+47
-16
@@ -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,23 +22,43 @@ 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)));
|
||||
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
|
||||
const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count);
|
||||
const next = button('→', 'Nächste Folie', () => manual(() => step(1)));
|
||||
const next = button('→', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
|
||||
const dots = [];
|
||||
if (slider.classList.contains('slider-dots')) {
|
||||
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
|
||||
@@ -44,24 +67,32 @@ export function initSliders(doc = document) {
|
||||
});
|
||||
}
|
||||
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
|
||||
if (play) play.className = 'slider-play';
|
||||
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.textContent = playing ? 'Pause' : 'Wiedergabe'; play.dataset.playing = String(playing);
|
||||
// Der zugängliche Name beginnt mit dem sichtbaren Text (WCAG 2.5.3): wer „Pause“ liest, kann „Pause“ sagen.
|
||||
play.setAttribute('aria-label', playing ? 'Pause: automatische Wiedergabe anhalten' : 'Wiedergabe: automatisch weiterblättern');
|
||||
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 and controls share a positioned shell, so arrows and dots can lie on the picture
|
||||
// the way the Kant-IT hero slider draws them.
|
||||
const shell = doc.createElement('div'); shell.className = 'slider-shell';
|
||||
slider.replaceWith(shell); shell.append(slider, 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 +102,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(); shell.replaceWith(slider); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
|
||||
});
|
||||
return () => cleanups.forEach(cleanup => cleanup());
|
||||
}
|
||||
|
||||
+11
-3
@@ -25,14 +25,19 @@
|
||||
panels[index].querySelector('.tabs-heading')?.classList.add('visually-hidden');
|
||||
});
|
||||
|
||||
const available = () => links.map((link, i) => getComputedStyle(link.parentElement).display !== 'none' ? i : -1).filter(i => i >= 0);
|
||||
let selected = -1;
|
||||
const select = (index, moveFocus = true) => {
|
||||
const visible = available();
|
||||
if (!visible.includes(index)) index = visible[0] ?? -1;
|
||||
selected = index;
|
||||
links.forEach((link, i) => {
|
||||
const current = i === index;
|
||||
link.setAttribute('aria-selected', String(current));
|
||||
link.tabIndex = current ? 0 : -1;
|
||||
panels[i].hidden = !current;
|
||||
});
|
||||
if (moveFocus) links[index].focus();
|
||||
if (moveFocus && index >= 0) links[index].focus();
|
||||
};
|
||||
|
||||
list.addEventListener('click', event => {
|
||||
@@ -46,15 +51,18 @@
|
||||
list.addEventListener('keydown', event => {
|
||||
const index = links.indexOf(event.target.closest('a'));
|
||||
if (index === -1) return;
|
||||
const moves = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: links.length - 1 };
|
||||
const visible = available(), position = visible.indexOf(index);
|
||||
if (!visible.length) return;
|
||||
const moves = { ArrowRight: position + 1, ArrowLeft: position - 1, Home: 0, End: visible.length - 1 };
|
||||
if (!(event.key in moves)) return;
|
||||
event.preventDefault();
|
||||
select((moves[event.key] + links.length) % links.length);
|
||||
select(visible[(moves[event.key] + visible.length) % visible.length]);
|
||||
});
|
||||
|
||||
// A link from elsewhere may point straight at one panel.
|
||||
const deepLink = panels.findIndex(panel => panel.id === location.hash.slice(1));
|
||||
select(deepLink === -1 ? 0 : deepLink, false);
|
||||
window.addEventListener('resize', () => select(selected, false));
|
||||
group.classList.add('tabs-ready');
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { buttonSample } from './button-design.js';
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
const root = document.querySelector('#button-defaults');
|
||||
if (root) {
|
||||
const schema = JSON.parse(root.dataset.schema), form = root.closest('form');
|
||||
const read = () => Object.fromEntries([...root.querySelectorAll('[name^="buttons["]')].map(input => [input.name.slice(8, -1), input.value]));
|
||||
const update = () => {
|
||||
// Unsaved palette edits are reflected immediately in all samples.
|
||||
schema.colors = [...form.querySelectorAll('[data-color-card]')].flatMap(row => {
|
||||
const key = row.querySelector('[name$="[key]"]'), value = row.querySelector('[name$="[value]"]');
|
||||
return key?.value && value ? [{ key: key.value, value: value.value }] : [];
|
||||
});
|
||||
const values = read(), previews = root.querySelector('.button-state-previews'); previews.replaceChildren();
|
||||
for (const [label, hover] of [['Normal', false], ['Beim Überfahren', true]]) {
|
||||
const state = document.createElement('div'); state.className = 'button-state';
|
||||
const title = document.createElement('span'); title.className = 'field-help'; title.textContent = label;
|
||||
state.append(title, buttonSample('Mehr erfahren', values, schema, hover)); previews.append(state);
|
||||
}
|
||||
root.querySelectorAll('[data-button-preset]').forEach((choice, index) => {
|
||||
const preset = schema.buttonPresets[index]; choice.setAttribute('aria-pressed', String(preset.shape === values.shape && preset.appearance === values.appearance));
|
||||
choice.querySelector('.cms-button')?.remove();
|
||||
const sample = buttonSample('Button', { ...values, ...preset, size: 'small' }, schema); sample.setAttribute('aria-hidden', 'true'); choice.prepend(sample);
|
||||
});
|
||||
};
|
||||
for (const input of root.querySelectorAll('[data-button-color]')) {
|
||||
const label = root.querySelector(`label[for="${input.id}"]`).textContent;
|
||||
const originalId = input.id; input.type = 'hidden'; input.id += '-value';
|
||||
const control = createColorControl({ id: originalId, label, value: input.value, colors: schema.colors, onChange: value => { input.value = value; update(); } });
|
||||
control.querySelector('.color-inherit').hidden = true;
|
||||
input.after(control);
|
||||
}
|
||||
root.querySelectorAll('[data-button-preset]').forEach((choice, index) => choice.addEventListener('click', () => {
|
||||
const preset = schema.buttonPresets[index];
|
||||
for (const key of ['shape', 'appearance']) root.querySelector(`[name="buttons[${key}]"]`).value = preset[key];
|
||||
update();
|
||||
}));
|
||||
form.addEventListener('input', update); form.addEventListener('change', update); update();
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Die Designseite: Palette, Schriften, Grundtypografie.
|
||||
*
|
||||
* Ohne Skript sind es Karten mit Eingaben; eine Farbe ohne Schlüssel und eine Schrift ohne
|
||||
* Schlüssel werden nicht gespeichert, so lassen sie sich auch dann entfernen. Das Skript macht
|
||||
* daraus die eigentliche Werkstatt: Karten hinzufügen und entfernen, fertige Paletten übernehmen,
|
||||
* die Grundfarben aus der Palette wählen statt tippen, jede Schrift in sich selbst zeigen und die
|
||||
* Grundtypografie als lesbare Probe.
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
const form = document.querySelector('[data-theme-editor]');
|
||||
if (form) {
|
||||
const palettes = JSON.parse(form.dataset.palettes), colorNames = JSON.parse(form.dataset.colorNames);
|
||||
const colorList = form.querySelector('[data-color-list]'), colorTemplate = form.querySelector('[data-color-template]');
|
||||
const fontList = form.querySelector('[data-font-list]'), fontTemplate = form.querySelector('[data-font-template]');
|
||||
const colorCards = () => [...colorList.querySelectorAll('[data-color-card]')];
|
||||
const fontCards = () => [...fontList.querySelectorAll('[data-font-card]')];
|
||||
let nextColor = colorCards().length, nextFont = fontCards().length;
|
||||
const value = (card, selector) => card.querySelector(selector).value.trim();
|
||||
|
||||
const palette = () => colorCards().flatMap(card => {
|
||||
const key = value(card, '[data-color-key]').toLowerCase();
|
||||
return key ? [{ key, name: value(card, '[data-color-name]') || key, value: card.querySelector('[data-color-value]').value }] : [];
|
||||
});
|
||||
const fonts = () => fontCards().flatMap(card => {
|
||||
const key = value(card, '[data-font-key]').toLowerCase();
|
||||
return key ? [{ key, name: value(card, '[data-font-name]') || key, family: value(card, '[data-font-family]'), fallback: card.querySelector('[data-font-fallback]').value }] : [];
|
||||
});
|
||||
const stack = font => font.family ? `"${font.family}", ${font.fallback}` : font.fallback;
|
||||
const hex = raw => /^#[0-9a-f]{6}/i.test(raw || '') ? raw.slice(0, 7) : palette().find(color => `@${color.key}` === raw)?.value || '';
|
||||
|
||||
/* ---- Palette ---------------------------------------------------------------------- */
|
||||
|
||||
const refreshColors = () => {
|
||||
colorCards().forEach((card, index) => {
|
||||
const name = value(card, '[data-color-name]') || value(card, '[data-color-key]') || `Farbe ${index + 1}`;
|
||||
card.querySelector('[data-color-hex]').textContent = card.querySelector('[data-color-value]').value;
|
||||
card.querySelector('[data-color-value]').setAttribute('aria-label', `Farbwert von ${name}`);
|
||||
const remove = card.querySelector('[data-remove-color]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
|
||||
});
|
||||
form.querySelector('[data-color-count]').textContent = String(palette().length);
|
||||
};
|
||||
const addColor = (values = {}) => {
|
||||
colorList.insertAdjacentHTML('beforeend', colorTemplate.innerHTML.replaceAll('__n__', String(nextColor++)));
|
||||
const card = colorList.lastElementChild; card.classList.remove('swatch-card-new');
|
||||
for (const [selector, field] of [['[data-color-key]', 'key'], ['[data-color-name]', 'name'], ['[data-color-value]', 'value']]) {
|
||||
if (values[field] !== undefined) card.querySelector(selector).value = values[field];
|
||||
}
|
||||
return card;
|
||||
};
|
||||
// Die leeren Karten braucht es nur ohne Skript: hier kommt jede Farbe über den Knopf.
|
||||
const emptyColors = colorCards().filter(card => card.classList.contains('swatch-card-new'));
|
||||
if (colorCards().length > emptyColors.length) emptyColors.forEach(card => card.remove());
|
||||
const addColorButton = form.querySelector('[data-add-color]'); addColorButton.hidden = false;
|
||||
addColorButton.addEventListener('click', () => {
|
||||
const card = addColor(); refreshColors(); mountBaseColors(); card.querySelector('[data-color-name]').focus();
|
||||
});
|
||||
colorList.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-color]');
|
||||
if (!button) return;
|
||||
button.closest('[data-color-card]').remove(); refreshColors(); mountBaseColors(); renderType(); addColorButton.focus();
|
||||
});
|
||||
colorList.addEventListener('input', () => { refreshColors(); renderType(); });
|
||||
colorList.addEventListener('change', () => { mountBaseColors(); renderType(); });
|
||||
|
||||
const presets = form.querySelector('[data-palette-presets]'), status = form.querySelector('[data-palette-status]');
|
||||
presets.hidden = false;
|
||||
presets.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-palette-preset]');
|
||||
if (!button) return;
|
||||
const preset = palettes[Number(button.dataset.palettePreset)];
|
||||
// Bekannte Schlüssel bekommen den neuen Wert, fehlende kommen dazu, eigene Farben bleiben.
|
||||
for (const [key, color] of Object.entries(preset.colors)) {
|
||||
const card = colorCards().find(candidate => value(candidate, '[data-color-key]').toLowerCase() === key);
|
||||
if (card) card.querySelector('[data-color-value]').value = color;
|
||||
else addColor({ key, name: colorNames[key] || key, value: color });
|
||||
}
|
||||
refreshColors(); mountBaseColors(); renderType();
|
||||
status.textContent = `Palette „${preset.name}“ übernommen – sie gilt, sobald das Design gespeichert ist.`;
|
||||
});
|
||||
|
||||
/* ---- Schriften -------------------------------------------------------------------- */
|
||||
|
||||
const applySample = card => {
|
||||
const family = value(card, '[data-font-family]'), fallback = card.querySelector('[data-font-fallback]').value;
|
||||
card.querySelector('[data-font-sample]').style.fontFamily = stack({ family, fallback });
|
||||
const chosen = new Set([...card.querySelectorAll('[data-font-weight]')].filter(input => input.checked).map(input => input.value));
|
||||
for (const weight of card.querySelectorAll('[data-font-weights] > span')) weight.hidden = !chosen.has(weight.className.replace('font-weight-', ''));
|
||||
};
|
||||
const syncFontSelects = () => {
|
||||
const list = fonts();
|
||||
for (const select of form.querySelectorAll('[data-font-select]')) {
|
||||
const current = select.value, keep = select.querySelector('option[value="inherit"]');
|
||||
select.replaceChildren(...(keep ? [keep] : []), ...list.map(font => new Option(font.name, font.key)));
|
||||
select.value = list.some(font => font.key === current) || (keep && current === 'inherit') ? current : (keep ? 'inherit' : list[0]?.key ?? '');
|
||||
}
|
||||
};
|
||||
const refreshFonts = () => {
|
||||
fontCards().forEach((card, index) => {
|
||||
applySample(card);
|
||||
const name = value(card, '[data-font-name]') || value(card, '[data-font-key]') || `Schrift ${index + 1}`;
|
||||
const remove = card.querySelector('[data-remove-font]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
|
||||
});
|
||||
form.querySelector('[data-font-count]').textContent = String(fonts().length);
|
||||
syncFontSelects();
|
||||
};
|
||||
const emptyFonts = fontCards().filter(card => card.classList.contains('font-card-new'));
|
||||
if (fontCards().length > emptyFonts.length) emptyFonts.forEach(card => card.remove());
|
||||
const addFontButton = form.querySelector('[data-add-font]'); addFontButton.hidden = false;
|
||||
addFontButton.addEventListener('click', () => {
|
||||
fontList.insertAdjacentHTML('beforeend', fontTemplate.innerHTML.replaceAll('__n__', String(nextFont++)));
|
||||
const card = fontList.lastElementChild; card.classList.remove('font-card-new');
|
||||
refreshFonts(); card.querySelector('[data-font-name]').focus();
|
||||
});
|
||||
fontList.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-font]');
|
||||
if (!button) return;
|
||||
button.closest('[data-font-card]').remove(); refreshFonts(); renderType(); addFontButton.focus();
|
||||
});
|
||||
fontList.addEventListener('input', () => { refreshFonts(); renderType(); });
|
||||
fontList.addEventListener('change', () => { refreshFonts(); renderType(); });
|
||||
|
||||
/* ---- Grundtypografie -------------------------------------------------------------- */
|
||||
|
||||
const preview = form.querySelector('[data-type-preview]');
|
||||
const baseInputs = {};
|
||||
const lengthPattern = /^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/;
|
||||
function renderType() {
|
||||
const list = fonts();
|
||||
const family = key => { const font = list.find(entry => entry.key === key); return font ? stack(font) : 'inherit'; };
|
||||
const raw = form.querySelector('#base-size').value.trim(), size = lengthPattern.test(raw) ? raw : '18px';
|
||||
const line = form.querySelector('#base-line').value || '1.6', scale = parseFloat(form.querySelector('#base-scale').value) || 1.25;
|
||||
preview.style.fontFamily = family(form.querySelector('#base-font').value);
|
||||
preview.style.fontSize = size; preview.style.lineHeight = line;
|
||||
const heading = family(form.querySelector('#base-heading-font').value);
|
||||
for (const [selector, step] of [['[data-type-h1]', 4], ['[data-type-h2]', 3]]) {
|
||||
const node = preview.querySelector(selector);
|
||||
node.style.fontFamily = heading; node.style.fontSize = `calc(${size} * ${(scale ** step).toFixed(4)})`;
|
||||
}
|
||||
preview.style.color = hex(baseInputs.color?.value) || '#203b34';
|
||||
preview.style.background = hex(baseInputs.backgroundColor?.value) || '#ffffff';
|
||||
const link = preview.querySelector('[data-type-link]');
|
||||
link.style.color = hex(baseInputs.linkColor?.value) || 'inherit';
|
||||
link.style.textDecoration = form.querySelector('[data-base-underline]').checked ? 'underline' : 'none';
|
||||
}
|
||||
// Die drei Grundfarben als Palettenwahl. Die Palette kann sich unter ihnen ändern, deshalb
|
||||
// werden die Bedienelemente bei jeder Änderung der Palette neu aufgebaut; der Wert bleibt.
|
||||
function mountBaseColors() {
|
||||
const colors = palette();
|
||||
for (const input of form.querySelectorAll('[data-base-color]')) {
|
||||
const key = input.name.slice(5, -1);
|
||||
if (!input.dataset.controlId) {
|
||||
input.dataset.controlId = input.id; input.type = 'hidden'; input.id += '-value'; baseInputs[key] = input;
|
||||
}
|
||||
const id = input.dataset.controlId, label = form.querySelector(`label[for="${id}"]`).textContent;
|
||||
input.nextElementSibling?.classList.contains('color-control') && input.nextElementSibling.remove();
|
||||
const control = createColorControl({
|
||||
id, label, value: input.value, colors,
|
||||
onChange: chosen => { input.value = chosen; renderType(); }, onComplete: () => {},
|
||||
});
|
||||
control.querySelector('.color-inherit').hidden = true;
|
||||
input.after(control);
|
||||
}
|
||||
}
|
||||
|
||||
refreshColors(); refreshFonts(); mountBaseColors(); renderType();
|
||||
form.addEventListener('input', renderType); form.addEventListener('change', renderType);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ try {
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('.field-table tbody tr').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
await page.locator('.field-card').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
console.log('Public no-JavaScript baseline and visual captures passed.');
|
||||
} else {
|
||||
if (!process.argv.includes('--admin-only')) {
|
||||
@@ -72,11 +72,11 @@ try {
|
||||
await page.mouse.move(1, 1); await page.waitForTimeout(7200);
|
||||
console.log('Paused slider:', JSON.stringify(await geometry()), await controls.innerText());
|
||||
assert.ok(Math.abs((await geometry()).left - manual.left) < 2, 'manual navigation pauses autoplay');
|
||||
await controls.getByRole('button', { name: 'Automatische Wiedergabe starten' }).click();
|
||||
await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).click();
|
||||
await page.mouse.move(1, 1); await page.waitForTimeout(7700);
|
||||
assert.ok(Math.abs((await geometry()).left - manual.left) > 20, 'play resumes after manual operation');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
assert.equal(await controls.getByRole('button', { name: 'Automatische Wiedergabe starten' }).isDisabled(), true);
|
||||
assert.equal(await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).isDisabled(), true);
|
||||
const reduced = await geometry(); await page.waitForTimeout(7100);
|
||||
assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay');
|
||||
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Exercise the real HTTP update across two complete release archives in an isolated installation.
|
||||
// Usage: PHP_BINARY=php node scripts/archive-update-check.mjs old.zip new.zip [expected-status]
|
||||
// UPDATE_SOURCE_VERSION changes only the disposable source VERSION to test a future upgrade
|
||||
// from the fixed updater before a subsequent release exists.
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, readFile, writeFile, readdir, access } from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const [source, target, expected = '302'] = process.argv.slice(2);
|
||||
assert.ok(source && target, 'Supply the installed and target release ZIP paths.');
|
||||
const root = process.cwd().replaceAll('\\', '/');
|
||||
const run = `${root}/var/archive-check-${randomBytes(5).toString('hex')}`;
|
||||
await mkdir(run, { recursive: true });
|
||||
const originalHash = createHash('sha256').update(await readFile('var/kantit.db')).digest('hex');
|
||||
execFileSync(php, ['-r', '$z=new ZipArchive(); if(true!==$z->open($argv[1])||!$z->extractTo($argv[2]))exit(1); $z->close();', path.resolve(source), run], { windowsHide: true });
|
||||
const [directory] = (await readdir(run)).filter(name => /^kantsites-/.test(name));
|
||||
assert.ok(directory);
|
||||
const installation = `${run}/${directory}`;
|
||||
const targetVersion = execFileSync(php, ['-r', '$z=new ZipArchive(); $z->open($argv[1]); for($i=0;$i<$z->numFiles;$i++){if(preg_match("#^kantsites-[^/]+/VERSION$#",$z->getNameIndex($i)))echo trim($z->getFromIndex($i));}', path.resolve(target)], { encoding: 'utf8', windowsHide: true }).trim();
|
||||
assert.match(targetVersion, /^\d+\.\d+\.\d+$/);
|
||||
if (process.env.UPDATE_SOURCE_VERSION) await writeFile(`${installation}/VERSION`, `${process.env.UPDATE_SOURCE_VERSION}\n`);
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
await mkdir(`${installation}/var/media`, { recursive: true });
|
||||
// Only the download transport is replaced; the real archive, checksum, backup, file swap,
|
||||
// production kernel, session, redirects, migration and subsequent requests remain in use.
|
||||
await writeFile(`${run}/download.php`, `<?php
|
||||
class ArchiveCheckDownload extends App\\Ops\\Download {
|
||||
public function get(string $url): ?string {
|
||||
if(str_ends_with($url, '/releases/latest')) return json_encode(['tag_name'=>'v'.getenv('QA_TARGET_VERSION')]);
|
||||
if(str_ends_with($url, '.zip.sha256')) return hash_file('sha256', getenv('QA_TARGET_ZIP'));
|
||||
if(str_ends_with($url, '.zip')) return file_get_contents(getenv('QA_TARGET_ZIP'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
`);
|
||||
await writeFile(`${installation}/config/services.yaml`, (await readFile(`${installation}/config/services.yaml`, 'utf8')) + '\n App\\Ops\\Download:\n class: ArchiveCheckDownload\n');
|
||||
// Keep the test router outside the replaced bin/ directory (Windows locks the active script).
|
||||
await writeFile(`${run}/router.php`, `<?php
|
||||
$root = ${JSON.stringify(installation)};
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
if ($uri !== '/' && !str_contains($uri, '..') && is_file($root.'/public'.$uri)) return false;
|
||||
require_once $root.'/vendor/autoload.php';
|
||||
require_once ${JSON.stringify(`${run}/download.php`)};
|
||||
require $root.'/public/index.php';
|
||||
`);
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: `${installation}/var/media`, SITE_ORIGIN: origin, QA_TARGET_ZIP: path.resolve(target), QA_TARGET_VERSION: targetVersion };
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', `${run}/router.php`], { cwd: installation, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let log = '', browser;
|
||||
server.stdout.on('data', data => log += data); server.stderr.on('data', data => log += data);
|
||||
const result = { source: path.resolve(source), target: path.resolve(target), simulatedSourceVersion: process.env.UPDATE_SOURCE_VERSION ?? null };
|
||||
try {
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin + '/admin/login')).ok) { ready = true; break; } } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
assert.ok(ready, 'Isolated release must boot before testing an update.');
|
||||
browser = await chromium.launch(); const page = await browser.newPage();
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.locator('button[type=submit]').click();
|
||||
assert.equal((await page.goto(origin + '/admin/system')).status(), 200);
|
||||
const token = await page.locator('form[action="/admin/system/archiv"] [name="_csrf_token"]').first().inputValue();
|
||||
const response = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: token, target: targetVersion }, maxRedirects: 0 });
|
||||
result.updateStatus = response.status(); assert.equal(response.status(), Number(expected));
|
||||
if (response.status() === 302) assert.equal(response.headers().location, '/admin/system');
|
||||
assert.equal((await readFile(`${installation}/VERSION`, 'utf8')).trim(), targetVersion);
|
||||
const updateLog = await readFile(`${installation}/var/updates.log`, 'utf8');
|
||||
assert.ok(updateLog.includes('Dateien sind ersetzt')); assert.ok(!updateLog.includes('FEHLER'));
|
||||
assert.equal((await page.request.get(origin)).status(), 503, 'Maintenance stays active until migrations finish.');
|
||||
result.systemStatus = (await page.goto(origin + '/admin/system')).status(); assert.equal(result.systemStatus, 200);
|
||||
const finish = page.locator('form').filter({ has: page.locator('input[name=finish]') });
|
||||
assert.equal(await finish.count(), 1, 'The migration step remains accessible with the same session.');
|
||||
const csrf = await finish.locator('[name="_csrf_token"]').inputValue();
|
||||
const finished = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: csrf, finish: '1' }, maxRedirects: 0 });
|
||||
result.finishStatus = finished.status(); assert.equal(finished.status(), 302);
|
||||
result.publicStatus = (await page.request.get(origin)).status(); assert.equal(result.publicStatus, 200);
|
||||
assert.equal((await page.goto(origin + '/admin/system')).status(), 200);
|
||||
assert.equal(await page.locator('input[name=finish]').count(), 0);
|
||||
assert.equal((await readdir(`${installation}/var/backups`)).filter(name => name.startsWith('sicherung-')).length, 1);
|
||||
await access(`${installation}/.env`);
|
||||
assert.equal(createHash('sha256').update(await readFile('var/kantit.db')).digest('hex'), originalHash);
|
||||
if (Number(expected) === 302) assert.doesNotMatch(log, /\[critical\]|Failed to open stream|PHP Fatal error/);
|
||||
result.ok = true;
|
||||
} finally {
|
||||
await browser?.close(); server.kill();
|
||||
await writeFile(`${run}/server.log`, log); await writeFile(`${run}/result.json`, JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify({ ...result, report: run }, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
const file = new URL('../public/assets/buttons.css', import.meta.url);
|
||||
const source = (await readFile(file, 'utf8')).split('/* Responsive variants */')[0].trimEnd();
|
||||
const rules = source.split('\n').filter(line => line.includes('b-desktop-')).join('\n');
|
||||
await writeFile(file, `${source}\n/* Responsive variants */\n@media(max-width:1024px){\n${rules.replaceAll('b-desktop-', 'b-tablet-')}\n}\n@media(max-width:767px){\n${rules.replaceAll('b-desktop-', 'b-mobile-')}\n}\n`);
|
||||
@@ -0,0 +1,139 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/buttons'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
const buttonStyle = node => {
|
||||
const style = getComputedStyle(node);
|
||||
return Object.fromEntries(['backgroundColor', 'color', 'borderRadius', 'borderColor', 'borderWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'textTransform', 'lineHeight', 'textAlign'].map(key => [key, style[key]]));
|
||||
};
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
data.document.blocks.unshift({ id: 'qa-buttons-section', type: 'section', children: [
|
||||
{ id: 'qa-button-default', type: 'button', props: { text: 'Kontakt aufnehmen', href: '#kontakt' } },
|
||||
{ id: 'qa-button', type: 'button', props: { text: 'Mehr erfahren', href: '/kontakt' }, style: { desktop: { margin: { left: '12px' }, fontSize: '17px' } } }
|
||||
] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(origin + '/admin/theme');
|
||||
await page.locator('#buttons-fontSize').fill('18');
|
||||
await page.getByRole('button', { name: 'Standardbutton Rahmen · Pille', exact: true }).click();
|
||||
assert.equal(await page.locator('#buttons-shape').inputValue(), 'pill');
|
||||
assert.equal(await page.locator('#buttons-appearance').inputValue(), 'outline');
|
||||
assert.equal(await page.locator('#button-defaults .button-state .cms-button').first().evaluate(el => getComputedStyle(el).borderRadius), '999px');
|
||||
await page.locator('#button-defaults').screenshot({ path: `${report}/theme.png` });
|
||||
await page.getByRole('button', { name: 'Design speichern', exact: true }).click();
|
||||
assert.equal(await page.locator('#buttons-shape').inputValue(), 'pill');
|
||||
assert.equal(await page.locator('#buttons-fontSize').inputValue(), '18');
|
||||
checks.push('global typography, visual presets, normal/hover preview and actual form save including empty palette rows');
|
||||
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await page.locator('#structure [data-block-id="qa-button"]').click();
|
||||
await page.getByRole('button', { name: 'Button-Vorlage Abgerundet', exact: true }).click();
|
||||
const waitVariant = async (id, radius, background) => page.waitForFunction(({id,radius,background}) => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, el = doc?.getElementById(id);
|
||||
if (!el) return false;
|
||||
const s = doc.defaultView.getComputedStyle(el);
|
||||
return s.borderRadius === radius && (background === undefined || (s.backgroundColor === 'rgba(0, 0, 0, 0)') === background);
|
||||
}, { id, radius, background });
|
||||
await waitVariant('qa-button', '8px', false);
|
||||
await page.locator('#undo').click(); await waitVariant('qa-button', '999px', true);
|
||||
await page.locator('#redo').click(); await waitVariant('qa-button', '8px', false);
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await page.getByRole('button', { name: 'Button-Vorlage Rahmen · eckig', exact: true }).click();
|
||||
await waitVariant('qa-button', '0px', true);
|
||||
await page.locator('[data-viewport="mobile"]').click();
|
||||
await page.getByRole('button', { name: 'Button-Vorlage Pille', exact: true }).click();
|
||||
await waitVariant('qa-button', '999px', false);
|
||||
await page.locator('#button-reset').click(); await waitVariant('qa-button', '0px', true);
|
||||
await page.locator('#undo').click(); await waitVariant('qa-button', '999px', false);
|
||||
await page.locator('[data-viewport="desktop"]').click(); await waitVariant('qa-button', '8px', false);
|
||||
await page.locator('.button-designer').screenshot({ path: `${report}/editor-controls.png` });
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await page.locator('#structure [data-block-id="qa-button"]').click(); await waitVariant('qa-button', '8px', false);
|
||||
const stored = (await (await context.request.get(api)).json()).document;
|
||||
assert.equal(stored.schemaVersion, 21);
|
||||
assert.deepEqual(stored.blocks[0].children[1].props, { text: 'Mehr erfahren', href: '/kontakt' });
|
||||
assert.equal(stored.blocks[0].children[1].style.desktop.margin.left, '12px');
|
||||
assert.equal(stored.blocks[0].children[1].style.desktop.fontSize, '17px');
|
||||
checks.push('preset selection, device inheritance/reset, undo/redo, save/reload, preserved content and unrelated styling');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
|
||||
for (const [width, radius, transparent] of [[1600, '8px', false], [900, '0px', true], [390, '999px', false]]) {
|
||||
await publicPage.setViewportSize({ width, height: 900 }); await publicPage.goto(origin);
|
||||
const necessaryCookies = publicPage.getByRole('button', { name: 'Nur notwendige', exact: true });
|
||||
if (await necessaryCookies.count()) await necessaryCookies.click();
|
||||
const values = await publicPage.locator('#qa-button').evaluate(el => { const s=getComputedStyle(el);return { radius:s.borderRadius, bg:s.backgroundColor, font:s.fontSize }; });
|
||||
assert.equal(values.radius, radius); assert.equal(values.bg === 'rgba(0, 0, 0, 0)', transparent); assert.equal(values.font, '17px');
|
||||
assert.equal(await publicPage.locator('#qa-button-default').evaluate(el => getComputedStyle(el).fontSize), '18px');
|
||||
const submit = publicPage.locator('.block-form button[type=submit]').first();
|
||||
const standard = publicPage.locator('#qa-button-default');
|
||||
assert.deepEqual(await submit.evaluate(buttonStyle), await standard.evaluate(buttonStyle), 'Form submit uses global shape, appearance, size, typography and colors');
|
||||
assert.ok(await submit.evaluate(node => node.getBoundingClientRect().width < node.form.getBoundingClientRect().width), 'Submit keeps natural button width');
|
||||
await submit.hover(); await publicPage.waitForTimeout(350);
|
||||
const hoverStyle = await submit.evaluate(buttonStyle);
|
||||
await standard.hover(); await publicPage.waitForTimeout(350);
|
||||
assert.deepEqual(hoverStyle, await standard.evaluate(buttonStyle), 'Submit inherits the global hover colors');
|
||||
await submit.focus();
|
||||
assert.equal(await submit.evaluate(node => getComputedStyle(node).outlineStyle), 'solid', 'Submit has a keyboard focus indicator');
|
||||
await submit.screenshot({ path: `${report}/submit-${width}.png` });
|
||||
await publicPage.locator('#qa-buttons-section').screenshot({ path: `${report}/public-${width}.png` });
|
||||
}
|
||||
await page.goto(origin + '/admin/theme');
|
||||
await page.locator('#buttons-shape').selectOption('rounded');
|
||||
await page.locator('#buttons-appearance').selectOption('solid');
|
||||
await page.locator('#buttons-size').selectOption('large');
|
||||
await page.locator('#buttons-fontSize').fill('20');
|
||||
for (const [key, hex] of [['backgroundColor', '#124b36'], ['color', '#ffffff'], ['hoverBackgroundColor', '#092c20'], ['hoverColor', '#fff4cc']]) {
|
||||
await page.locator(`#buttons-${key}`).click();
|
||||
await page.locator(`#buttons-${key}-hex`).fill(hex);
|
||||
await page.locator(`#buttons-${key}-hex`).press('Enter');
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
await page.getByRole('button', { name: 'Design speichern', exact: true }).click();
|
||||
await publicPage.goto(origin);
|
||||
const updatedSubmit = publicPage.locator('.block-form button[type=submit]').first();
|
||||
const updatedDefault = publicPage.locator('#qa-button-default');
|
||||
assert.deepEqual(await updatedSubmit.evaluate(buttonStyle), await updatedDefault.evaluate(buttonStyle), 'A later change to solid global defaults updates existing forms');
|
||||
assert.equal(await updatedSubmit.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(18, 75, 54)');
|
||||
await updatedSubmit.hover(); await publicPage.waitForTimeout(350);
|
||||
const updatedHover = await updatedSubmit.evaluate(buttonStyle);
|
||||
await updatedDefault.hover(); await publicPage.waitForTimeout(350);
|
||||
assert.deepEqual(updatedHover, await updatedDefault.evaluate(buttonStyle), 'Custom global hover colors update existing forms');
|
||||
await publicPage.setViewportSize({ width: 390, height: 1600 });
|
||||
await updatedSubmit.scrollIntoViewIfNeeded();
|
||||
await publicPage.locator('.block-form').first().screenshot({ path: `${report}/form-standard-button.png` });
|
||||
await plain.close(); checks.push('published global defaults, form submit including hover/focus/natural width, solid and outline theme changes, and local responsive variants without JavaScript');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Button checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
+61
-48
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { mediaProps, upgradeDocument } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/image-alignment'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
|
||||
// Geometry of the visible image as well as its figure: centering an empty, full-width
|
||||
// figure alone must not pass while the logo inside it stays at the left edge.
|
||||
const geometry = figure => {
|
||||
const img = figure.querySelector('img');
|
||||
const parent = figure.parentElement, style = getComputedStyle(parent);
|
||||
const p = parent.getBoundingClientRect(), f = figure.getBoundingClientRect(), i = img.getBoundingClientRect();
|
||||
const left = p.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft);
|
||||
const width = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
const top = p.top + parseFloat(style.borderTopWidth) + parseFloat(style.paddingTop);
|
||||
const height = parent.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
||||
return { left: f.left - left, right: left + width - f.right, width: f.width, available: width,
|
||||
center: i.left + i.width / 2 - left - width / 2, innerCenter: i.left + i.width / 2 - f.left - f.width / 2,
|
||||
verticalCenter: f.top + f.height / 2 - top - height / 2, imageWidth: i.width, imageHeight: i.height,
|
||||
align: getComputedStyle(figure).alignSelf };
|
||||
};
|
||||
const closeTo = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 2, `${message}: ${actual} vs ${expected}`);
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
upgradeDocument(data.document, 20);
|
||||
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
|
||||
const chosen = media.find(item => /teamviewer/i.test(item.title)); assert.ok(chosen);
|
||||
const original = mediaProps(chosen); original.alt ||= chosen.title;
|
||||
const small = { ...original, width: 180, height: Math.round(180 * original.height / original.width) };
|
||||
const image = (id, style, props = original) => ({ id, type: 'image', props: structuredClone(props), style });
|
||||
const centered = { desktop: { width: '40%', alignSelf: 'center' } };
|
||||
const container = (id, style, child) => ({ id, type: 'column', style, children: [child] });
|
||||
const section = { id: 'qa-alignment-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px', padding: { left: '20px', right: '20px' } } }, children: [
|
||||
container('qa-flow', { desktop: { padding: { left: '18px', right: '18px' } } }, image('qa-center', { desktop: { width: '50%' } })),
|
||||
image('qa-natural', { desktop: { alignSelf: 'center' } }, small),
|
||||
image('qa-linked', { desktop: { width: '60%', alignSelf: 'center', margin: { left: '11px', right: '7px' } }, tablet: { alignSelf: 'flex-end' }, mobile: { alignSelf: 'auto' } }, { ...small, click: 'link', href: '/kontakt', newTab: true }),
|
||||
container('qa-grid', { desktop: { display: 'grid', gridColumns: '1', height: '160px' } }, image('qa-grid-image', centered)),
|
||||
container('qa-flex-column', { desktop: { display: 'flex', flexDirection: 'column', height: '160px' } }, image('qa-column-image', centered)),
|
||||
container('qa-flex-row', { desktop: { display: 'flex', height: '160px' } }, image('qa-row-image', centered)),
|
||||
container('qa-responsive-parent', { desktop: { display: 'flex', height: '160px' }, tablet: { flexDirection: 'column' }, mobile: { display: 'block' } }, image('qa-responsive-image', centered)),
|
||||
image('qa-zoom', { desktop: { maxWidth: '300px', alignSelf: 'center' } }, { ...small, click: 'vergroessern' }),
|
||||
] };
|
||||
data.document.blocks.unshift(section);
|
||||
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
|
||||
assert.equal(created.status(), 200, await created.text());
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const select = async () => {
|
||||
await page.locator('#structure [data-block-id="qa-center"]').click(); await page.locator('[data-panel="design"]').click();
|
||||
const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Layout$/ }) });
|
||||
if (await group.getAttribute('open') === null) await group.locator('summary').click();
|
||||
};
|
||||
const preview = () => page.frameLocator('#preview');
|
||||
const aligned = async value => {
|
||||
await page.waitForFunction(value => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, figure = doc?.getElementById('qa-center');
|
||||
return figure && doc.defaultView.getComputedStyle(figure).alignSelf === value;
|
||||
}, value);
|
||||
return preview().locator('#qa-center').evaluate(geometry);
|
||||
};
|
||||
await select(); closeTo((await aligned('auto')).left, 0, 'Initial left edge');
|
||||
await page.locator('#style-alignSelf').selectOption('center');
|
||||
closeTo((await aligned('center')).center, 0, 'Center in normal column');
|
||||
await page.locator('#undo').click(); closeTo((await aligned('auto')).left, 0, 'Undo centering');
|
||||
await page.locator('#redo').click(); closeTo((await aligned('center')).center, 0, 'Redo centering');
|
||||
await page.locator('#style-alignSelf').focus(); await page.locator('#style-alignSelf').press('ArrowDown');
|
||||
closeTo((await aligned('flex-end')).right, 0, 'Keyboard end alignment');
|
||||
await page.locator('#style-alignSelf').selectOption('flex-start'); closeTo((await aligned('flex-start')).left, 0, 'Start alignment');
|
||||
await page.locator('#style-alignSelf').selectOption('center'); await aligned('center');
|
||||
checks.push('actual Design control: center, start, end, keyboard, undo and redo in a normal padded column');
|
||||
|
||||
const checkLayout = async (root, device) => {
|
||||
for (const id of ['qa-center', 'qa-natural', 'qa-grid-image', 'qa-column-image', 'qa-zoom']) {
|
||||
closeTo((await root.locator(`#${id}`).evaluate(geometry)).center, 0, `${device} ${id} centered`);
|
||||
}
|
||||
const normal = await root.locator('#qa-center').evaluate(geometry);
|
||||
closeTo(normal.width / normal.available * 100, 50, 'Percentage width preserved');
|
||||
const natural = await root.locator('#qa-natural').evaluate(geometry);
|
||||
closeTo(natural.imageWidth, 180, 'Natural logo is not stretched');
|
||||
const row = await root.locator('#qa-row-image').evaluate(geometry);
|
||||
closeTo(row.left, 0, 'Flex row keeps its main-axis position'); closeTo(row.verticalCenter, 0, 'Flex row retains vertical centering');
|
||||
const changing = await root.locator('#qa-responsive-image').evaluate(geometry);
|
||||
closeTo(device === 'desktop' ? changing.left : changing.center, 0, 'Responsive parent layout');
|
||||
const linked = await root.locator('#qa-linked').evaluate(geometry);
|
||||
if (device === 'desktop') closeTo(linked.center, 0, 'Linked image centered');
|
||||
if (device === 'tablet') { closeTo(linked.right, 0, 'Tablet figure at end'); const img = await root.locator('#qa-linked img').boundingBox(); const fig = await root.locator('#qa-linked').boundingBox(); closeTo(img.x + img.width, fig.x + fig.width, 'Small linked image at end'); }
|
||||
if (device === 'mobile') { closeTo(linked.left, 11, 'Auto restores authored margin'); closeTo(linked.innerCenter, -(linked.width - linked.imageWidth) / 2, 'Auto clears inherited internal alignment'); }
|
||||
assert.equal(await root.locator('#qa-linked a').getAttribute('target'), '_blank');
|
||||
closeTo(normal.imageWidth / normal.imageHeight, original.width / original.height, 'Aspect ratio preserved');
|
||||
};
|
||||
for (const device of ['desktop', 'tablet', 'mobile']) {
|
||||
await page.locator(`[data-viewport="${device}"]`).click(); await aligned('center');
|
||||
await checkLayout(preview(), device);
|
||||
}
|
||||
await page.locator('[data-viewport="desktop"]').click(); await aligned('center');
|
||||
await preview().locator('#qa-flow').screenshot({ path: `${report}/centered-column.png` });
|
||||
await page.locator('#style-alignSelf').scrollIntoViewIfNeeded(); await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true });
|
||||
checks.push('preview: natural, percentage and maximum widths; links/zoom; grid and flex axes; responsive parent and auto reset');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await select(); closeTo((await aligned('center')).center, 0, 'Reload preserves alignment');
|
||||
const stored = await (await context.request.get(api)).json();
|
||||
const block = stored.document.blocks[0].children[0].children[0];
|
||||
assert.deepEqual(block.props, original); assert.deepEqual(block.style, { desktop: { width: '50%', alignSelf: 'center' } });
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
|
||||
for (const [width, device] of [[1600, 'desktop'], [900, 'tablet'], [390, 'mobile']]) {
|
||||
await publicPage.setViewportSize({ width, height: 1000 }); await publicPage.goto(origin);
|
||||
await checkLayout(publicPage, device);
|
||||
await publicPage.locator('#qa-alignment-section').screenshot({ path: `${report}/published-${device}.png` });
|
||||
}
|
||||
await plain.close(); checks.push('save/reload, unchanged media metadata, published desktop/tablet/mobile with JavaScript disabled');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Image alignment checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/navigation-ux'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
data.document.blocks.push({ id: 'qa-anchor', type: 'section', children: [{ id: 'qa-anchor-title', type: 'heading', props: { text: 'Kontaktbereich', level: 2 } }] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await page.locator('#structure [data-block-id="qa-anchor"]').click();
|
||||
await page.locator('#property-anchor').fill('#kontakt-check'); await page.locator('#property-anchor').blur();
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const stored = (await (await context.request.get(api)).json()).document;
|
||||
assert.equal(stored.blocks.at(-1).anchor, 'kontakt-check'); assert.equal(stored.schemaVersion, 21);
|
||||
checks.push('named jump target created in editor and saved/published with stable block ID');
|
||||
|
||||
await page.goto(origin + '/admin/menus');
|
||||
const rows = page.locator('#menu-items > li');
|
||||
const originalFirst = await rows.first().locator('[data-key="label"]').inputValue();
|
||||
const handle = rows.first().locator('[data-menu-drag]');
|
||||
const start = await handle.boundingBox(), end = await rows.last().boundingBox();
|
||||
await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); await page.mouse.down();
|
||||
await page.mouse.move(start.x + start.width / 2, end.y + end.height - 2, { steps: 12 }); await page.mouse.up();
|
||||
assert.equal(await rows.last().locator('[data-key="label"]').inputValue(), originalFirst);
|
||||
await page.locator('#menu-undo').click(); assert.equal(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst);
|
||||
await rows.first().locator('[data-menu-drag]').focus(); await page.keyboard.press('ArrowDown');
|
||||
assert.notEqual(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst);
|
||||
await page.locator('#menu-undo').click();
|
||||
await rows.first().locator('summary').click();
|
||||
await rows.first().locator('[data-key="icon"]').selectOption('phone');
|
||||
await rows.first().locator('[data-key="iconPosition"]').selectOption('after');
|
||||
assert.equal(await rows.first().locator('.menu-entry-title > svg').count(), 1);
|
||||
await page.getByText('Eigener Link', { exact: true }).first().click();
|
||||
await page.locator('#link-label').fill('Zum Kontaktbereich'); await page.locator('#link-url').fill('#kontakt-check');
|
||||
await page.locator('#menu-add-url').click();
|
||||
await page.getByRole('button', { name: 'Menü speichern', exact: true }).click();
|
||||
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
|
||||
await page.reload();
|
||||
assert.equal(await rows.first().locator('[data-key="icon"]').inputValue(), 'phone');
|
||||
assert.equal(await rows.first().locator('[data-key="iconPosition"]').inputValue(), 'after');
|
||||
await rows.first().locator('summary').click();
|
||||
await page.screenshot({ path: `${report}/menus.png`, fullPage: true });
|
||||
checks.push('pointer drag, keyboard ordering, undo, icon placement, fragment menu link and save/reload');
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
|
||||
await publicPage.goto(origin);
|
||||
assert.ok(await publicPage.locator('.nav-label > svg').count() > 0);
|
||||
await publicPage.getByRole('link', { name: 'Zum Kontaktbereich', exact: true }).first().click();
|
||||
assert.ok(publicPage.url().endsWith('#kontakt-check'));
|
||||
assert.equal(await publicPage.locator('#kontakt-check').count(), 1);
|
||||
assert.ok(await publicPage.evaluate(() => window.scrollY) > 0);
|
||||
await plain.close();
|
||||
execFileSync(php, ['-r', 'require "vendor/autoload.php"; $db=App\\Infrastructure\\ConnectionFactory::create($argv[1]); $db->executeStatement("UPDATE scheduled_task SET last_status = ?, last_message = ? WHERE name = ?", ["ok", "Fassung 99.0.0 ist verfügbar; installiert ist 1.8.4.", "update-check"]);', fixture.databaseUrl], { windowsHide: true });
|
||||
await page.reload();
|
||||
assert.equal(await page.getByRole('link', { name: 'Update 99.0.0 verfügbar. Aktualisierung öffnen.', exact: true }).count(), 1);
|
||||
assert.match(await page.locator('.version-update').getAttribute('href'), /system\?pruefen=1$/);
|
||||
await page.locator('.shell-foot').screenshot({ path: `${report}/update-hint.png` });
|
||||
checks.push('public icon and native anchor navigation without JavaScript; cached update notice by version');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Navigation checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Elektro-Bernhard-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
export DATABASE_URL="sqlite:///$root/var/elektro-bernhard.db"
|
||||
export MEDIA_DIRECTORY="$root/var/elektro-bernhard-media"
|
||||
rm -f "$root/var/elektro-bernhard.db"
|
||||
rm -rf "$root/var/elektro-bernhard-media"
|
||||
php vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
php -d memory_limit=512M scripts/seed-elektro-bernhard.php "$1"
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Moldovan-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/tagespflege-moldovan.db"
|
||||
export MEDIA_DIRECTORY="$root/var/tagespflege-moldovan-media"
|
||||
rm -f "$root/var/tagespflege-moldovan.db"
|
||||
rm -rf "$root/var/tagespflege-moldovan-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/tagespflege-moldovan-assets}"
|
||||
"$php" -d memory_limit=512M scripts/seed-tagespflege-moldovan.php "$assets"
|
||||
@@ -0,0 +1,166 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/responsive-navigation'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
data.document.schemaVersion = 22;
|
||||
const visibility = ['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'];
|
||||
const text = (id, title, visible = 'all') => ({ id, type: 'text', props: { text: `<p>${title}</p>` }, layout: { visibility: visible } });
|
||||
const nav = (id, layout = 'horizontal', collapse) => ({ id, type: 'menu', props: { slot: 'header', layout, ...(collapse ? { collapse } : {}) } });
|
||||
data.document.blocks.unshift({ id: 'qa-responsive', type: 'section', children: [
|
||||
{ id: 'qa-title', type: 'heading', props: { text: 'Gerätesichtbarkeit und Navigation', level: 2 } },
|
||||
nav('qa-menu'), nav('qa-mobile-menu', 'horizontal', 'mobile'), nav('qa-never-menu', 'horizontal', 'never'), nav('qa-vertical', 'vertical'), nav('qa-burger', 'burger'),
|
||||
...visibility.map(value => text(`qa-${value}`, value, value)),
|
||||
{ id: 'qa-parent', type: 'column', layout: { visibility: 'mobile' }, style: { desktop: { display: 'flex' }, mobile: { display: 'grid' } }, children: [text('qa-child', 'Vererbte Sichtbarkeit')] },
|
||||
{ id: 'qa-tabs', type: 'tabs', children: [
|
||||
{ id: 'qa-tab-desktop', type: 'tabitem', props: { title: 'Desktop-Tab' }, layout: { visibility: 'desktop' }, children: [text('qa-tab-text-d', 'Desktopinhalt')] },
|
||||
{ id: 'qa-tab-mobile', type: 'tabitem', props: { title: 'Mobil-Tab' }, layout: { visibility: 'mobile' }, children: [text('qa-tab-text-m', 'Mobilinhalt')] },
|
||||
{ id: 'qa-tab-all', type: 'tabitem', props: { title: 'Immer-Tab' }, children: [text('qa-tab-text-a', 'Gemeinsamer Inhalt')] },
|
||||
] },
|
||||
{ id: 'qa-slider', type: 'slider', props: { desktopVisible: 1, tabletVisible: 1, mobileVisible: 1, snap: true, dots: true, autoplay: 0 }, children: [text('qa-slide-d', 'Desktopfolie', 'desktop'), text('qa-slide-m', 'Mobilfolie', 'mobile'), text('qa-slide-a', 'Gemeinsame Folie')] },
|
||||
] });
|
||||
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
|
||||
assert.equal(created.status(), 200, await created.text());
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await page.locator('#structure [data-block-id="qa-all"]').click();
|
||||
await page.locator('#block-visibility').selectOption('mobile');
|
||||
const previewHidden = async hidden => page.waitForFunction(hidden => {
|
||||
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-all');
|
||||
return el && (el.getClientRects().length === 0) === hidden;
|
||||
}, hidden);
|
||||
await previewHidden(true);
|
||||
await page.locator('#undo').click(); await previewHidden(false);
|
||||
await page.locator('#redo').click(); await previewHidden(true);
|
||||
await page.locator('#structure [data-block-id="qa-all"]').click();
|
||||
await page.locator('#block-visibility').selectOption('all'); await previewHidden(false);
|
||||
await page.locator('#block-visibility').focus(); await page.locator('#block-visibility').press('ArrowDown'); await page.locator('#block-visibility').press('Tab');
|
||||
assert.equal(await page.locator('#block-visibility').inputValue(), 'desktop');
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#structure [data-block-id="qa-menu"]').click();
|
||||
assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet');
|
||||
await page.locator('#property-collapse').selectOption('never');
|
||||
await page.locator('#undo').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet');
|
||||
await page.locator('#redo').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'never');
|
||||
await page.locator('#property-collapse').selectOption('tablet');
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
const preview = page.frameLocator('#preview');
|
||||
await preview.locator('#qa-menu > .menu-burger > summary').click();
|
||||
assert.equal(await preview.locator('#qa-menu > .menu-burger').getAttribute('open'), '');
|
||||
await page.screenshot({ path: `${report}/editor-tablet.png`, fullPage: true });
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload();
|
||||
await page.locator('#structure [data-block-id="qa-menu"]').click();
|
||||
assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet');
|
||||
await page.locator('#structure [data-block-id="qa-mobile"]').click();
|
||||
assert.equal(await page.locator('#block-visibility').inputValue(), 'mobile');
|
||||
await page.locator('[data-panel="design"]').click(); assert.equal(await page.locator('#block-visibility').inputValue(), 'mobile');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const stored = (await (await context.request.get(api)).json()).document;
|
||||
assert.equal(stored.schemaVersion, 22);
|
||||
assert.deepEqual(stored.blocks[0].children.find(b => b.id === 'qa-parent').style, data.document.blocks[0].children.find(b => b.id === 'qa-parent').style);
|
||||
checks.push('Editor: visibility keyboard selection, hidden block selection, reset, undo/redo, hamburger preview, save/reload/publish and preserved layout');
|
||||
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1600, height: 1050 } });
|
||||
const publicPage = await plain.newPage(); await publicPage.goto(origin);
|
||||
const necessary = publicPage.getByRole('button', { name: 'Nur notwendige', exact: true });
|
||||
if (await necessary.isVisible()) await necessary.click();
|
||||
for (const width of [1600, 1025, 1024, 768, 767, 390]) {
|
||||
await publicPage.setViewportSize({ width, height: 1050 });
|
||||
const device = width > 1024 ? 'desktop' : width > 767 ? 'tablet' : 'mobile';
|
||||
for (const value of visibility) {
|
||||
const shown = value === 'all' || value === device || (value.startsWith('hide-') && value !== `hide-${device}`);
|
||||
assert.equal(await publicPage.locator(`#qa-${value}`).isVisible(), shown, `${width}: ${value}`);
|
||||
}
|
||||
assert.equal(await publicPage.locator('#qa-child').isVisible(), device === 'mobile');
|
||||
if (device === 'mobile') assert.equal(await publicPage.locator('#qa-parent').evaluate(el => getComputedStyle(el).display), 'grid');
|
||||
assert.equal(await publicPage.locator('#qa-menu > .menu-burger > summary').isVisible(), width <= 1024);
|
||||
assert.equal(await publicPage.locator('#qa-menu > .menu-list').isVisible(), width > 1024);
|
||||
assert.equal(await publicPage.locator('#qa-mobile-menu > .menu-burger > summary').isVisible(), width <= 767);
|
||||
assert.equal(await publicPage.locator('#qa-never-menu > .menu-list').isVisible(), true);
|
||||
assert.equal(await publicPage.locator('#qa-vertical > .menu-list').isVisible(), true);
|
||||
assert.equal(await publicPage.locator('#qa-burger > .menu-burger > summary').isVisible(), true);
|
||||
if ([1600, 1024, 390].includes(width)) { await publicPage.evaluate(() => window.scrollTo(0, 0)); await publicPage.screenshot({ path: `${report}/public-${width}.png`, fullPage: true }); }
|
||||
}
|
||||
const summary = publicPage.locator('#qa-menu > .menu-burger > summary');
|
||||
await summary.focus(); await publicPage.keyboard.press('Enter');
|
||||
assert.equal(await publicPage.locator('#qa-menu > .menu-burger').getAttribute('open'), '');
|
||||
await publicPage.keyboard.press('Tab');
|
||||
assert.ok(await publicPage.evaluate(() => document.activeElement.closest('#qa-menu > .menu-burger')));
|
||||
const branch = publicPage.locator('#qa-menu .menu-burger .menu-branch > summary').first();
|
||||
if (await branch.count()) { await branch.focus(); await publicPage.keyboard.press('Space'); assert.ok(await branch.evaluate(el => el.parentElement.open)); }
|
||||
await summary.focus(); await publicPage.keyboard.press('Space');
|
||||
assert.equal(await publicPage.locator('#qa-menu > .menu-burger').getAttribute('open'), null);
|
||||
await summary.press('Enter');
|
||||
const firstLink = publicPage.locator('#qa-menu .menu-burger a:visible').first();
|
||||
const href = await firstLink.getAttribute('href'); await firstLink.click(); assert.equal(publicPage.url(), new URL(href, origin).href);
|
||||
await plain.close();
|
||||
checks.push('Public without JavaScript: six viewport boundaries, every visibility preset, parent visibility, default and manual menu variants, keyboard toggle and real navigation');
|
||||
|
||||
const tablet = await browser.newContext({ javaScriptEnabled: false, hasTouch: true, isMobile: true, viewport: { width: 1366, height: 1024 } });
|
||||
const ipad = await tablet.newPage(); await ipad.goto(origin);
|
||||
assert.equal(await ipad.locator('#qa-menu > .menu-burger > summary').isVisible(), true);
|
||||
assert.equal(await ipad.locator('#qa-menu > .menu-list').isVisible(), false);
|
||||
await ipad.locator('#qa-menu > .menu-burger > summary').tap();
|
||||
assert.equal(await ipad.locator('#qa-menu > .menu-burger').getAttribute('open'), '');
|
||||
await ipad.locator('#qa-menu').screenshot({ path: `${report}/large-tablet.png` }); await tablet.close();
|
||||
checks.push('Large tablet: 1366 px touch viewport uses the hamburger and opens by touch without JavaScript');
|
||||
|
||||
const enhanced = await browser.newContext({ viewport: { width: 1600, height: 1050 }, bypassCSP: true });
|
||||
const dynamic = await enhanced.newPage(); await dynamic.goto(origin);
|
||||
await dynamic.locator('#qa-tabs.tabs-ready').waitFor();
|
||||
const audit = async () => {
|
||||
const result = await new AxeBuilder({ page: dynamic }).include('#qa-menu').include('#qa-tabs').include('#qa-slider + .slider-controls').withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(result.violations.map(item => ({ id: item.id, nodes: item.nodes.map(node => node.target) })), []);
|
||||
};
|
||||
await audit();
|
||||
assert.equal(await dynamic.locator('#qa-tab-desktop').isVisible(), true);
|
||||
await dynamic.setViewportSize({ width: 390, height: 850 });
|
||||
await dynamic.locator('#qa-tab-mobile').waitFor({ state: 'visible' });
|
||||
await dynamic.locator('#qa-menu > .menu-burger > summary').click(); await audit();
|
||||
await dynamic.locator('#qa-tab-mobile-tab').focus(); await dynamic.keyboard.press('ArrowRight');
|
||||
assert.equal(await dynamic.locator('#qa-tab-all').isVisible(), true);
|
||||
await dynamic.keyboard.press('ArrowRight'); assert.equal(await dynamic.locator('#qa-tab-mobile').isVisible(), true);
|
||||
const controls = dynamic.locator('#qa-slider + .slider-controls');
|
||||
await controls.scrollIntoViewIfNeeded();
|
||||
assert.match(await controls.locator('.slider-position').innerText(), /\/ 2$/);
|
||||
assert.equal(await controls.getByRole('button', { name: 'Folie 1 anzeigen' }).isVisible(), false);
|
||||
await controls.getByRole('button', { name: 'Nächste Folie' }).click();
|
||||
await dynamic.waitForFunction(() => document.querySelector('#qa-slider + .slider-controls .slider-position').textContent === '2 / 2');
|
||||
await enhanced.close();
|
||||
checks.push('Enhanced groups: tabs select a visible panel and skip hidden tabs; slider skips hidden slides and counts visible ones; desktop/mobile WCAG checks of navigation, tabs and slider controls');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Responsive navigation checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,875 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut die Website „Elektro Bernhard“ als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Inhalt: das gelieferte HTML-Paket (12.09.2026), abgeglichen mit elektro-bernhard.de (14.09.2026).
|
||||
* Gestaltung: eigener Entwurf nach dem Muster des laufenden Auftritts – dunkler Kopf über einem
|
||||
* Foto, Karten mit Schatten, Einblendungen, ein Kopfbereich, der über dem Bild schwebt und beim
|
||||
* Scrollen weiß wird. Alles aus gewöhnlichen Bausteinen; kein eigener Baustein, kein Sonderweg.
|
||||
*
|
||||
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
|
||||
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-elektro-bernhard.sh).
|
||||
* Erstes Argument: Ordner mit den Originalbildern (logo.webp, hero-1200.webp, electrician-900.webp).
|
||||
*/
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$assets = $argv[1] ?? null;
|
||||
if (null === $assets || !is_dir($assets)) {
|
||||
fwrite(STDERR, "Bitte den Ordner mit den Originalbildern als Argument angeben.\n");
|
||||
exit(1);
|
||||
}
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
|
||||
if (!str_contains($url, 'elektro-bernhard')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die Elektro-Bernhard-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
|
||||
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$media = new MediaLibrary($db, $mediaDirectory);
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$validator = new DocumentValidator();
|
||||
$pages = new PageRepository($db, $validator, $settings);
|
||||
$partials = new PartialRepository($db, $validator);
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
$forms = new FormRepository($db);
|
||||
$theme = new Theme($db);
|
||||
$redirects = new RedirectRepository($db);
|
||||
|
||||
$mail = 'info@elektro-bernhard.de';
|
||||
$buero = '07221 3944909';
|
||||
$notdienst = '0151 25245851';
|
||||
$SCHEMA = 23;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Bausteine
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Ein Abschnitt mit dem Rhythmus der Seite: 96 px am Schreibtisch, 64 mobil. */
|
||||
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null): array {
|
||||
$basis = ['desktop' => ['padding' => ['top' => '96px', 'bottom' => '96px']], 'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']]];
|
||||
foreach ($style as $device => $values) {
|
||||
$basis[$device] = [...($basis[$device] ?? []), ...$values];
|
||||
}
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $basis];
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$row = static function (string $id, array $children, array $design = [], array $style = []): array {
|
||||
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
|
||||
if ([] !== $design) {
|
||||
$block['layout'] = ['design' => $design];
|
||||
}
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$column = static function (string $id, array $children, array $style = [], string $sichtbarkeit = 'all'): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('all' !== $sichtbarkeit) {
|
||||
$block['layout'] = ['visibility' => $sichtbarkeit];
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$heading = static function (string $id, string $text, int $level, array $style = []): array {
|
||||
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$text = static function (string $id, string $text, array $style = []): array {
|
||||
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $text]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Einblendung und Hover-Bewegung, wie Flatsome sie jedem Element anbietet. */
|
||||
$fx = static function (array $block, string $entrance = 'rise', string $delay = 'none', ?string $hover = null, ?string $shadow = null): array {
|
||||
$effects = ['entrance' => $entrance, 'delay' => $delay];
|
||||
if (null !== $hover) {
|
||||
$effects['hover'] = $hover;
|
||||
}
|
||||
if (null !== $shadow) {
|
||||
$effects['shadow'] = $shadow;
|
||||
}
|
||||
$block['layout'] = [...($block['layout'] ?? []), 'effects' => $effects];
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Die Kleinzeile über einer Überschrift: Versalien, gesperrt, in Rot. */
|
||||
$eyebrow = static fn (string $id, string $inhalt, string $farbe = '@rot'): array => [
|
||||
'id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt],
|
||||
'style' => ['desktop' => ['color' => $farbe, 'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.16em', 'textTransform' => 'uppercase', 'lineHeight' => 1.4, 'margin' => ['bottom' => '16px']]],
|
||||
];
|
||||
|
||||
$button = static function (string $id, string $text, string $href, array $style = [], array $variante = []): array {
|
||||
$block = ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ([] !== $variante) {
|
||||
$block['layout'] = ['button' => $variante];
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $caption = null, ?string $href = null): array {
|
||||
$gross = end($eintrag['variants']);
|
||||
$props = [
|
||||
'src' => $gross['src'], 'alt' => $alt, 'decorative' => false, 'width' => $gross['width'], 'height' => $gross['height'],
|
||||
'mediaId' => $eintrag['id'],
|
||||
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
|
||||
];
|
||||
if (null !== $caption) {
|
||||
$props['caption'] = $caption;
|
||||
}
|
||||
if (null !== $href) {
|
||||
$props['href'] = $href;
|
||||
$props['click'] = 'link';
|
||||
}
|
||||
$block = ['id' => $id, 'type' => 'image', 'props' => $props];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$alleEcken = static fn (string $r): array => ['topLeft' => $r, 'topRight' => $r, 'bottomRight' => $r, 'bottomLeft' => $r];
|
||||
$alleSeiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
|
||||
|
||||
/** Eine Karte mit Symbol auf roter Fläche – die Leistungskarte des Auftritts. */
|
||||
$karte = static function (string $id, string $icon, string $titel, string $inhalt, string $href = '') use ($alleEcken, $alleSeiten): array {
|
||||
return [
|
||||
'id' => $id, 'type' => 'iconcard',
|
||||
'props' => ['icon' => $icon, 'title' => $titel, 'text' => $inhalt, 'href' => $href],
|
||||
'style' => ['desktop' => [
|
||||
'backgroundColor' => '@papier', 'color' => '@tinte',
|
||||
'padding' => $alleSeiten('32px'), 'borderWidth' => $alleSeiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie',
|
||||
'borderRadius' => $alleEcken('16px'), 'gap' => '14px',
|
||||
'hover' => ['borderColor' => '@rot'],
|
||||
]],
|
||||
'css' => "& svg{width:54px;height:54px;padding:12px;color:#c8102e;background:#fbe9ec;border-radius:14px}\n& h3{font-size:20px;letter-spacing:-0.01em}\n& p{color:#5d636b;font-size:15.5px}\n& h3 a:hover{color:#c8102e;text-decoration:none}",
|
||||
'layout' => ['effects' => ['shadow' => 'soft', 'hover' => 'lift', 'entrance' => 'rise']],
|
||||
];
|
||||
};
|
||||
|
||||
/** Eine schlanke Zeile mit Symbol, ohne Karte – für Aufzählungen von Stärken. */
|
||||
$punkt = static function (string $id, string $icon, string $titel, string $inhalt, string $farbe = '#c8102e', string $textfarbe = '#5d636b'): array {
|
||||
return [
|
||||
'id' => $id, 'type' => 'iconcard',
|
||||
'props' => ['icon' => $icon, 'title' => $titel, 'text' => $inhalt, 'href' => ''],
|
||||
'style' => ['desktop' => ['padding' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px'], 'borderStyle' => 'none', 'gap' => '6px']],
|
||||
'css' => "& svg{width:26px;height:26px;color:$farbe}\n& h3{font-size:17px}\n& p{color:$textfarbe;font-size:15px}",
|
||||
];
|
||||
};
|
||||
|
||||
$kontaktBlock = static function (string $id, array $props, array $style = []): array {
|
||||
$block = ['id' => $id, 'type' => 'contact', 'props' => ['phone' => $props['phone'] ?? '', 'email' => $props['email'] ?? '', 'address' => $props['address'] ?? '']];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Die sozialen Profile – gepflegt in den Einstellungen, hier nur Größe, Form und Farbe. */
|
||||
$sozial = static function (string $id, int $groesse, string $form, string $farbe, array $extra = []): array {
|
||||
return [
|
||||
'id' => $id, 'type' => 'social',
|
||||
'props' => ['size' => $groesse, 'shape' => $form, 'layout' => 'horizontal', 'labels' => false, 'networks' => [], 'newTab' => true],
|
||||
'style' => ['desktop' => ['color' => $farbe, ...$extra]],
|
||||
];
|
||||
};
|
||||
|
||||
/** Der Handlungsaufruf am Ende einer Seite: dunkle Karte mit rotem Knopf. */
|
||||
$cta = static function (string $id) use ($alleEcken, $alleSeiten): array {
|
||||
return [
|
||||
'id' => $id, 'type' => 'cta',
|
||||
'props' => ['title' => 'Projekt geplant? Wir sind bereit.', 'text' => 'Kontaktieren Sie uns für eine kostenlose Beratung – wir melden uns innerhalb von 24 Stunden.', 'buttonText' => 'Jetzt Kontakt aufnehmen', 'href' => '/kontakt'],
|
||||
'style' => ['desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => $alleSeiten('44px'), 'borderRadius' => $alleEcken('20px'), 'margin' => ['top' => '56px']], 'mobile' => ['padding' => $alleSeiten('28px')]],
|
||||
'css' => "& h2{font-size:28px;line-height:1.2;letter-spacing:-0.02em;margin:0 0 8px}\n& p{color:#c2c6cc;margin:0}",
|
||||
'layout' => ['effects' => ['entrance' => 'rise']],
|
||||
];
|
||||
};
|
||||
|
||||
// Wiederkehrende Gestaltungswerte.
|
||||
$fliess = ['desktop' => ['color' => '@gedaempft', 'fontSize' => '17px', 'lineHeight' => 1.75]];
|
||||
$fliessHell = ['desktop' => ['color' => '#c2c6cc', 'fontSize' => '17px', 'lineHeight' => 1.75]];
|
||||
$h2Stil = ['desktop' => ['fontSize' => '44px', 'lineHeight' => 1.12, 'letterSpacing' => '-0.03em', 'fontWeight' => '900', 'margin' => ['bottom' => '18px']], 'mobile' => ['fontSize' => '32px']];
|
||||
$h3Stil = ['desktop' => ['fontSize' => '21px', 'lineHeight' => 1.25, 'fontWeight' => '700', 'margin' => ['bottom' => '12px']]];
|
||||
$fussTitel = ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'textTransform' => 'uppercase', 'color' => '@papier', 'margin' => ['bottom' => '18px']]];
|
||||
$linkStil = ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'color' => '@rot', 'margin' => ['top' => '8px']]];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien, Formular
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'Elektro Bernhard GmbH', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'rot', 'name' => 'Signalrot', 'value' => '#c8102e'],
|
||||
['key' => 'rot-dunkel', 'name' => 'Signalrot dunkel', 'value' => '#a70c25'],
|
||||
['key' => 'rot-hell', 'name' => 'Rot, Fläche', 'value' => '#fbe9ec'],
|
||||
['key' => 'tinte', 'name' => 'Tinte', 'value' => '#151719'],
|
||||
['key' => 'nacht', 'name' => 'Fußbereich', 'value' => '#17191b'],
|
||||
['key' => 'balken', 'name' => 'Kopfleiste', 'value' => '#181a1d'],
|
||||
['key' => 'karte-dunkel', 'name' => 'Dunkle Karte', 'value' => '#24272b'],
|
||||
['key' => 'gedaempft', 'name' => 'Gedämpft', 'value' => '#5d636b'],
|
||||
['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'],
|
||||
['key' => 'weich', 'name' => 'Weiches Grau', 'value' => '#f4f5f6'],
|
||||
['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#e2e4e7'],
|
||||
],
|
||||
[
|
||||
// Lato liegt bereits lokal unter public/assets/fonts – keine Anfrage an Google.
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Lato', 'source' => 'google', 'weights' => [400, 700, 900], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700, 900], 'fallback' => 'sans-serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '17px', 'lineHeight' => 1.7, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@rot', 'containerWidth' => '1240px', 'headingScale' => 1.25, 'linkUnderline' => false],
|
||||
['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'large', 'fontFamily' => 'inherit', 'fontSize' => 13, 'fontWeight' => 700, 'letterSpacing' => 0.6, 'textTransform' => 'uppercase', 'backgroundColor' => '@rot', 'color' => '@papier', 'hoverBackgroundColor' => '@rot-dunkel', 'hoverColor' => '@papier'],
|
||||
);
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'logo' => ['logo.webp', 'Elektro Bernhard GmbH – Logo'],
|
||||
'team' => ['hero-1200.webp', 'Team und Firmenfahrzeuge von Elektro Bernhard'],
|
||||
'schaltschrank' => ['electrician-900.webp', 'Elektrofachkraft bei der Arbeit an einem Schaltschrank'],
|
||||
] as $schluessel => [$datei, $alt]) {
|
||||
$pfad = rtrim($assets, '/\\').'/'.$datei;
|
||||
if (!is_file($pfad)) {
|
||||
fwrite(STDERR, "Bild fehlt: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$eintrag = $media->upload(new UploadedFile($pfad, $datei, null, null, true));
|
||||
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
|
||||
$medien[$schluessel] = $media->find($eintrag['id']);
|
||||
echo "Bild übernommen: $datei\n";
|
||||
}
|
||||
|
||||
$forms->save('kontakt', 'Kontaktanfrage', [
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Name / Firma', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false],
|
||||
['name' => 'thema', 'type' => 'select', 'label' => 'Worum geht es?', 'required' => true, 'options' => ['Elektroinstallation', 'Smart Home', 'Beratung & Planung', 'Solarenergie', 'Sonstige Anfrage']],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true, 'help' => 'Was planen Sie und wo soll das Projekt umgesetzt werden?'],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich habe die Datenschutzhinweise gelesen. Meine Angaben werden zur Bearbeitung meiner Anfrage verwendet.'],
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Anfrage über elektro-bernhard.de', 'success' => 'Vielen Dank. Ihre Anfrage ist angekommen – wir melden uns innerhalb von 24 Stunden.', 'replyToField' => 'email'], 0);
|
||||
echo "Formular angelegt: kontakt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopfbereich: schwebt über dem dunklen Seitenkopf, wird beim Scrollen weiß
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf-leiste', [
|
||||
$row('kopf-leiste-zeile', [
|
||||
$column('kopf-leiste-ort', [
|
||||
$text('kopf-leiste-adresse', 'Ooser Hauptstr. 51 · 76532 Baden-Baden', ['desktop' => ['color' => '#c4c8ce', 'fontSize' => '13px', 'lineHeight' => 1.4]]),
|
||||
], [], 'hide-mobile'),
|
||||
$column('kopf-leiste-rechts', [
|
||||
// Auf dem Telefon bleibt in der Leiste nur die Notdienstnummer; die Profile stehen im Fuß.
|
||||
[...$sozial('kopf-sozial', 16, 'plain', '#c4c8ce', ['hover' => ['color' => '@papier']]), 'layout' => ['visibility' => 'hide-mobile']],
|
||||
['id' => 'kopf-leiste-menue', 'type' => 'menu', 'props' => ['slot' => 'secondary', 'layout' => 'horizontal', 'collapse' => 'never'],
|
||||
'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'color' => '@papier']]],
|
||||
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'flex-end', 'alignItems' => 'center', 'gap' => '22px'], 'mobile' => ['justifyContent' => 'center']]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'small'], 'mobile' => ['columns' => 'one']], ['desktop' => ['alignItems' => 'center', 'margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['backgroundColor' => '@balken', 'padding' => ['top' => '8px', 'bottom' => '8px'], 'borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '#ffffff14'], 'mobile' => ['padding' => ['top' => '8px', 'bottom' => '8px']]]),
|
||||
$section('kopf-haupt', [
|
||||
$row('kopf-haupt-zeile', [
|
||||
$column('kopf-marke', [
|
||||
$bild('kopf-logo', $medien['logo'], 'Elektro Bernhard GmbH – Beratung, Service, Installation, Notdienst',
|
||||
['desktop' => ['maxWidth' => '250px', 'backgroundColor' => '@papier', 'padding' => ['top' => '10px', 'right' => '14px', 'bottom' => '10px', 'left' => '14px'], 'borderRadius' => $alleEcken('10px')], 'mobile' => ['maxWidth' => '200px']], null, '/'),
|
||||
]),
|
||||
$column('kopf-navigation', [
|
||||
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet'],
|
||||
'style' => ['desktop' => ['justifyContent' => 'flex-end', 'fontWeight' => '700', 'fontSize' => '15px', 'gap' => '30px']]],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'two']], ['desktop' => ['alignItems' => 'center', 'minHeight' => '92px', 'margin' => ['top' => '0px']], 'mobile' => ['minHeight' => '72px']]),
|
||||
], [
|
||||
// Durchsichtig am Seitenanfang, weiß mit Schatten sobald gescrollt wird.
|
||||
'desktop' => ['backgroundColor' => '#ffffff00', 'color' => '@papier', 'padding' => ['top' => '6px', 'bottom' => '6px'],
|
||||
'shadowY' => '10px', 'shadowBlur' => '30px', 'shadowColor' => '#00000000',
|
||||
'scrolled' => ['backgroundColor' => '@papier', 'color' => '@tinte', 'shadowColor' => '#0000001f']],
|
||||
'mobile' => ['padding' => ['top' => '6px', 'bottom' => '6px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Fußbereich
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss-notdienst', [
|
||||
$row('fuss-notdienst-zeile', [
|
||||
$column('fuss-notdienst-text', [
|
||||
$text('fuss-notdienst-titel', '24h Elektro-Notdienst', ['desktop' => ['fontSize' => '20px', 'fontWeight' => '900', 'color' => '@papier', 'lineHeight' => 1.3]]),
|
||||
$text('fuss-notdienst-zusatz', 'Bei elektrischen Störungen rund um die Uhr für Baden-Baden und die Region erreichbar.', ['desktop' => ['fontSize' => '15px', 'color' => '#ffffffd9', 'margin' => ['top' => '4px']]]),
|
||||
]),
|
||||
$column('fuss-notdienst-nummer', [
|
||||
$kontaktBlock('fuss-notdienst-telefon', ['phone' => $notdienst], ['desktop' => ['fontSize' => '30px', 'fontWeight' => '900', 'color' => '@papier', 'justifyContent' => 'flex-end', 'letterSpacing' => '-0.02em'], 'mobile' => ['fontSize' => '24px', 'justifyContent' => 'flex-start']]),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['alignItems' => 'center', 'margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['backgroundColor' => '@rot', 'color' => '@papier', 'padding' => ['top' => '30px', 'bottom' => '30px']], 'mobile' => ['padding' => ['top' => '26px', 'bottom' => '26px']]]),
|
||||
$section('fuss-haupt', [
|
||||
$row('fuss-haupt-zeile', [
|
||||
$column('fuss-marke', [
|
||||
$bild('fuss-logo', $medien['logo'], 'Elektro Bernhard GmbH', ['desktop' => ['maxWidth' => '230px', 'backgroundColor' => '@papier', 'padding' => $alleSeiten('12px'), 'borderRadius' => $alleEcken('10px')]], null, '/'),
|
||||
$text('fuss-marke-text', "Elektroinstallation und Kundenservice\nin Baden-Baden und Umgebung – seit über 35 Jahren.", ['desktop' => ['color' => '#b3b8bf', 'fontSize' => '15px', 'margin' => ['top' => '20px', 'bottom' => '20px']]]),
|
||||
$sozial('fuss-sozial', 20, 'circle', '@papier', ['hover' => ['color' => '@rot']]),
|
||||
]),
|
||||
$column('fuss-website', [
|
||||
$heading('fuss-website-titel', 'Unsere Website', 3, $fussTitel),
|
||||
['id' => 'fuss-website-menue', 'type' => 'menu', 'props' => ['slot' => 'footer', 'layout' => 'vertical'], 'style' => ['desktop' => ['fontSize' => '15px', 'color' => '#c4c8ce']]],
|
||||
]),
|
||||
$column('fuss-kontakt', [
|
||||
$heading('fuss-kontakt-titel', 'Kontakt', 3, $fussTitel),
|
||||
$kontaktBlock('fuss-kontakt-daten', ['phone' => $buero, 'email' => $mail, 'address' => 'Ooser Hauptstr. 51, 76532 Baden-Baden'], ['desktop' => ['fontSize' => '15px', 'color' => '#c4c8ce', 'gap' => '10px']]),
|
||||
]),
|
||||
$column('fuss-zeiten', [
|
||||
$heading('fuss-zeiten-titel', 'Bürozeiten', 3, $fussTitel),
|
||||
$text('fuss-zeiten-text', "Mo–Fr: 07:30–17:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '#c4c8ce']]),
|
||||
$text('fuss-zeiten-notdienst', '<strong>Notdienst: 24/7 erreichbar</strong>', ['desktop' => ['fontSize' => '15px', 'color' => '@papier', 'margin' => ['top' => '10px']]]),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'four', 'gap' => 'large'], 'tablet' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
['id' => 'fuss-trenner', 'type' => 'divider', 'style' => ['desktop' => ['borderColor' => '#33373c', 'margin' => ['top' => '56px', 'bottom' => '24px']]]],
|
||||
$row('fuss-schluss', [
|
||||
$column('fuss-schluss-links', [$text('fuss-copyright', '© 2026 Elektro Bernhard GmbH', ['desktop' => ['color' => '#8f959c', 'fontSize' => '13px', 'lineHeight' => 1.8]])]),
|
||||
$column('fuss-schluss-rechts', [
|
||||
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'], 'style' => ['desktop' => ['color' => '#b3b8bf', 'fontSize' => '13px', 'justifyContent' => 'flex-end', 'padding' => ['right' => '72px']], 'mobile' => ['justifyContent' => 'flex-start', 'padding' => ['right' => '0px']]]],
|
||||
[...$text('fuss-urheber', 'Website von <a href="https://www.kant-it.de/">Kant-IT Solutions</a>', ['desktop' => ['color' => '#8f959c', 'fontSize' => '13px', 'textAlign' => 'right', 'margin' => ['top' => '6px'], 'padding' => ['right' => '72px']], 'mobile' => ['textAlign' => 'left', 'padding' => ['right' => '0px']]]), 'css' => '& a{color:#e8eaed}'],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['backgroundColor' => '@nacht', 'color' => '#b3b8bf', 'padding' => ['top' => '72px', 'bottom' => '24px']], 'mobile' => ['padding' => ['top' => '52px', 'bottom' => '24px']]]),
|
||||
],
|
||||
];
|
||||
|
||||
$partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Inhalte
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$leistungen = [
|
||||
['elektroinstallation', 'hardware', 'Elektroinstallation', 'Wir planen und installieren die Elektrik in Neubauten und übernehmen Elektroarbeiten bei Sanierungen.'],
|
||||
['smart-home', 'app', 'Smart Home', 'Beleuchtung, Heizung und Sicherheit zentral steuern. Wir beraten Sie, welche Lösung zu Ihrem Gebäude passt.'],
|
||||
['notdienst', 'phone', '24h Notdienst', 'Bei elektrischen Störungen erreichen Sie unseren Notdienst unter '.$notdienst.', auch außerhalb der Bürozeiten.'],
|
||||
['beratung-planung', 'design', 'Beratung & Planung', 'Sie möchten bauen oder umbauen? Wir besprechen mit Ihnen, welche Anschlüsse und Funktionen Sie benötigen.'],
|
||||
];
|
||||
|
||||
$fragen = [
|
||||
['Wo ist Elektro Bernhard im Einsatz?', 'Unser Standort ist in Baden-Baden. Wir betreuen Projekte in Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach und im Murgtal, Ettlingen und Karlsruhe sowie Achern, Oberkirch und Offenburg – von Karlsruhe bis Offenburg. Fragen Sie uns gerne zu Ihrem konkreten Projektort.'],
|
||||
['Übernehmen Sie Neubauten und Sanierungen?', 'Ja. Elektroinstallation für Neu- und Altbauten gehört zu unseren Leistungen. Wir beraten Sie zu Ihrem Vorhaben und planen gemeinsam die passende Umsetzung.'],
|
||||
['Was lässt sich mit Smart Home steuern?', 'Zum Beispiel Beleuchtung, Heizung und Sicherheitsfunktionen. Welche Steuerung zu Ihrem Gebäude passt, klären wir in einer individuellen Beratung.'],
|
||||
['Wie erreiche ich den 24h-Notdienst?', 'Bei einem elektrischen Notfall erreichen Sie unseren Kundendienst rund um die Uhr unter '.$notdienst.'. Für reguläre Projektanfragen erreichen Sie unser Büro unter '.$buero.'.'],
|
||||
['Bieten Sie auch Solaranlagen an?', 'Solarplanung und Installation übernimmt die eigenständige Elektro Bernhard Regenerative Energie GmbH. Das Angebot richtet sich an private Wohnhäuser und Geschäftsgebäude.'],
|
||||
['Wann ist das Büro erreichbar?', 'Montag bis Freitag von 07:30 bis 17:00 Uhr unter '.$buero.'. Außerhalb dieser Zeiten und am Wochenende hilft bei einem elektrischen Notfall der 24h-Notdienst unter '.$notdienst.'.'],
|
||||
['Wie läuft eine Anfrage ab?', 'Sie schildern uns Ihr Vorhaben und den Ort – per Formular, E-Mail oder Telefon. Wir melden uns innerhalb von 24 Stunden, klären offene Fragen und stimmen mit Ihnen einen Termin vor Ort oder ein Angebot ab.'],
|
||||
];
|
||||
|
||||
// Wortlaut, Name und Datum am 14.09.2026 aus den auf elektro-bernhard.de veröffentlichten
|
||||
// Google-Rezensionen übernommen (Zuordnung am DOM geprüft, nicht aus der Textfassung geraten).
|
||||
$stimmen = [
|
||||
['philipp', 'Als Architekt empfehle ich die Firma Bernhard gerne weiter. Zuverlässig, professionell und entspannt. Probleme werden zielführend gelöst und die Wünsche der Bauherren erfüllt. Solche Handwerker gibt es heutzutage leider immer seltener.', 'Philipp Bender', 'Google · 26. November 2024'],
|
||||
['soeren', 'Angefangen vom ersten Planungsgespräch, die Terminierung, Durchführung und Abnahme war ALLES perfekt. Obwohl wir tendenziell eher ein kleineres Projekt hatten, wurde alles sehr professionell und auf die Stunde genau pünktlich umgesetzt.', 'Sören de Vries', 'Google · 26. Oktober 2024'],
|
||||
['michael', 'Das Team hat sauber und gründlich gearbeitet und war immer sehr freundlich. Es wurden vollumfänglich Fragen beantwortet, wir wurden durch das Projekt regelrecht begleitet. Solche Handwerker findet man sehr selten.', 'Michael Pfeil', 'Google · 26. Oktober 2024'],
|
||||
['dijar', 'Ein erstklassiges Unternehmen – extrem zuverlässig und absolut kompetent. Bereits am Sonntag standen die Mitarbeiter, inklusive des Geschäftsführers, persönlich vor Ort. Absolute Empfehlung!', 'Dijar Bislimi', 'Google · 26. Oktober 2024'],
|
||||
['samir', 'Super Firma und einfach tolle Mitarbeiter', 'Redzepi Samir', 'Google · 26. Oktober 2024'],
|
||||
['artur', 'Von der Planung bis zur Ausführung top zufrieden. Sehr empfehlenswert.', 'Artur', 'Google · 9. Mai 2024'],
|
||||
];
|
||||
|
||||
$marken = ['Busch-Jaeger', 'JUNG', 'KNX', 'Gira', 'hager', 'Siedle'];
|
||||
|
||||
// Die sechs Punkte, mit denen der bestehende Auftritt unter „Über uns“ für sich wirbt.
|
||||
$staerken = [
|
||||
['zufriedenheit', 'star', 'Kundenzufriedenheit', 'Im Mittelpunkt steht der Kunde. Unser Ziel ist, Ihre Wünsche zu Ihrer vollsten Zufriedenheit zu erfüllen.'],
|
||||
['qualitaet', 'shield', 'Qualität', 'Ausschließlich Qualitätsware aus deutschen Markenprodukten.'],
|
||||
['erfahrung', 'clock', 'Erfahrung', 'Über 35 Jahre Erfahrung sprechen für sich.'],
|
||||
['flexibel', 'network', 'Flexibel', 'Durch zahlreiche Partner können wir auch bei komplexen Aufgaben schnell reagieren.'],
|
||||
['professionell', 'hardware', 'Professionell', 'Professionelle Werkzeuge für einen reibungslosen Ablauf auf Ihrer Baustelle.'],
|
||||
['sauber', 'check', 'Schnell & sauber', 'Saubere Arbeit in vergleichbar schnellem Arbeitstempo.'],
|
||||
];
|
||||
|
||||
$leistungstexte = [
|
||||
'elektroinstallation' => ['Elektroinstallation in Baden-Baden | Elektro Bernhard', 'Elektroinstallation für Neubau und Altbau', 'Sie bauen neu oder modernisieren ein bestehendes Gebäude? Wir begleiten Ihre Elektroinstallation von der Planung bis zur Umsetzung.', [
|
||||
['Neubau und Altbau', 'Die passende Elektroinstallation beginnt mit Ihren Anforderungen: Welche Räume nutzen Sie wie? Wo benötigen Sie Anschlüsse? Gemeinsam besprechen wir Ihr Vorhaben und schaffen eine Grundlage für die weitere Planung.'],
|
||||
['Material und Ausführung', 'Wir setzen auf Markenprodukte, professionelles Werkzeug und sorgfältige Ausführung. Auch bei komplexeren Aufgaben greifen wir auf unser Partnernetzwerk zurück.'],
|
||||
]],
|
||||
'smart-home' => ['Smart Home in Baden-Baden | Elektro Bernhard', 'Smart Home für Ihr Zuhause', 'Licht, Heizung und Sicherheitsfunktionen intelligent steuern: Wir planen Smart-Home-Lösungen passend zu Ihrem Gebäude und Ihren Gewohnheiten.', [
|
||||
['Was möchten Sie steuern?', 'Im Mittelpunkt steht Ihr Alltag. Wir besprechen, welche Funktionen Sie vereinfachen möchten und wie eine individuelle Steuerung dabei helfen kann.'],
|
||||
['Welche Technik passt zum Gebäude?', 'Bevor wir eine Lösung empfehlen, betrachten wir Ihre Wünsche und die Gegebenheiten vor Ort. So lässt sich klären, welche Technik zu Ihrem Projekt passt.'],
|
||||
]],
|
||||
'notdienst' => ['24h Elektro-Notdienst in Baden-Baden | Elektro Bernhard', '24h Elektro-Notdienst', 'Bei elektrischen Notfällen ist unser Kundendienst rund um die Uhr unter '.$notdienst.' erreichbar. Schildern Sie uns am Telefon das Problem und Ihren Standort.', [
|
||||
['Für die Region erreichbar', 'Unser Einsatzgebiet umfasst Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach und das Murgtal, Ettlingen und Karlsruhe sowie Achern, Oberkirch und Offenburg. Die konkrete Verfügbarkeit für Ihren Standort klären wir im Gespräch.'],
|
||||
['Der direkte Weg zur Hilfe', 'Für einen Notfall nutzen Sie bitte die Notdienstnummer. Das Kontaktformular ist für reguläre Anfragen gedacht und wird nicht durchgehend überwacht.'],
|
||||
]],
|
||||
'beratung-planung' => ['Elektroplanung & Beratung in Baden-Baden | Elektro Bernhard', 'Beratung und Elektroplanung', 'Ob erste Idee oder konkretes Vorhaben: Wir beraten Sie individuell und begleiten die Planung Ihrer Elektrotechnik.', [
|
||||
['Ihr Vorhaben', 'Erzählen Sie uns, was Sie vorhaben. Gemeinsam besprechen wir den Umfang und die Anforderungen Ihres Projekts – vom einzelnen Bereich bis zum Neu- oder Altbau.'],
|
||||
['Ein Ansprechpartner für die Umsetzung', 'Beratung, Planung und Installation greifen bei uns ineinander. Wir stimmen die nächsten Schritte mit Ihnen ab und behalten Ihre Anforderungen im Blick.'],
|
||||
]],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Wiederkehrende Abschnitte
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Der dunkle Seitenkopf jeder Unterseite. Er liegt unter dem schwebenden Kopfbereich, deshalb
|
||||
* beginnt sein Inhalt erst 200 px weiter unten; die Brotkrumen stehen darin in Weiß.
|
||||
*/
|
||||
$seitenkopf = static function (string $praefix, string $label, string $h1, string $intro = '', array $zusatz = []) use ($section, $eyebrow, $heading, $text, $fx, $fliessHell): array {
|
||||
$kinder = [
|
||||
['id' => $praefix.'-brotkrumen', 'type' => 'breadcrumb', 'style' => ['desktop' => ['color' => '#ffffffb3', 'margin' => ['bottom' => '28px']]]],
|
||||
$fx($eyebrow($praefix.'-label', $label, '#ff7d90'), 'fade'),
|
||||
$fx($heading($praefix.'-h1', $h1, 1, ['desktop' => ['fontSize' => '56px', 'lineHeight' => 1.08, 'letterSpacing' => '-0.03em', 'fontWeight' => '900', 'color' => '@papier', 'maxWidth' => '820px', 'margin' => ['bottom' => '18px']], 'mobile' => ['fontSize' => '36px']]), 'rise', 'short'),
|
||||
];
|
||||
if ('' !== $intro) {
|
||||
$kinder[] = $fx($text($praefix.'-intro', $intro, ['desktop' => [...$fliessHell['desktop'], 'fontSize' => '19px', 'maxWidth' => '680px']]), 'rise', 'medium');
|
||||
}
|
||||
foreach ($zusatz as $block) {
|
||||
$kinder[] = $fx($block, 'rise', 'long');
|
||||
}
|
||||
|
||||
return $section($praefix.'-kopf', $kinder, [
|
||||
'desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => ['top' => '200px', 'bottom' => '80px']],
|
||||
'mobile' => ['padding' => ['top' => '166px', 'bottom' => '56px']],
|
||||
]);
|
||||
};
|
||||
|
||||
/** Der schmale Textkörper einer Unterseite. */
|
||||
$textkoerper = static function (string $praefix, array $absaetze, array $vorspann = [], array $schluss = []) use ($section, $heading, $text, $fliess): array {
|
||||
$kinder = $vorspann;
|
||||
foreach ($absaetze as $i => [$titel, $inhalt]) {
|
||||
if ('' !== $titel) {
|
||||
$kinder[] = $heading($praefix.'-h2-'.$i, $titel, 2, ['desktop' => ['fontSize' => '28px', 'lineHeight' => 1.25, 'letterSpacing' => '-0.02em', 'fontWeight' => '900', 'margin' => ['top' => '40px', 'bottom' => '14px']]]);
|
||||
}
|
||||
$kinder[] = $text($praefix.'-p-'.$i, $inhalt, $fliess);
|
||||
}
|
||||
foreach ($schluss as $block) {
|
||||
$kinder[] = $block;
|
||||
}
|
||||
|
||||
return $section($praefix.'-inhalt', $kinder, ['desktop' => ['maxWidth' => '860px', 'padding' => ['top' => '72px', 'bottom' => '96px']], 'mobile' => ['padding' => ['top' => '48px', 'bottom' => '64px']]]);
|
||||
};
|
||||
|
||||
/** Der Kontaktabschnitt – auf Startseite und Kontaktseite derselbe Aufbau. */
|
||||
$kontaktAbschnitt = static function (string $praefix) use ($section, $row, $column, $heading, $text, $eyebrow, $kontaktBlock, $fx, $fliess, $h2Stil, $h3Stil, $alleEcken, $alleSeiten, $buero, $mail): array {
|
||||
return $section($praefix.'-kontakt', [
|
||||
$row($praefix.'-kontakt-zeile', [
|
||||
$column($praefix.'-kontakt-links', [
|
||||
$fx($eyebrow($praefix.'-kontakt-label', 'Kontakt'), 'fade'),
|
||||
$fx($heading($praefix.'-kontakt-titel', 'Wie können wir Ihnen helfen?', 2, $h2Stil), 'rise'),
|
||||
$fx($text($praefix.'-kontakt-text', 'Rufen Sie uns an oder schreiben Sie uns, welche Arbeiten Sie planen. Bitte geben Sie auch den Ort an – wir melden uns innerhalb von 24 Stunden.', $fliess), 'rise', 'short'),
|
||||
$fx($kontaktBlock($praefix.'-kontakt-daten', ['phone' => $buero, 'email' => $mail], ['desktop' => ['fontSize' => '28px', 'fontWeight' => '900', 'letterSpacing' => '-0.02em', 'gap' => '12px', 'margin' => ['top' => '18px', 'bottom' => '30px']], 'mobile' => ['fontSize' => '22px']]), 'rise', 'medium'),
|
||||
$row($praefix.'-kontakt-details', [
|
||||
$column($praefix.'-kontakt-anschrift', [
|
||||
$heading($praefix.'-kontakt-anschrift-titel', 'Hier finden Sie uns', 3, ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'margin' => ['bottom' => '8px']]]),
|
||||
$text($praefix.'-kontakt-anschrift-text', "Elektro Bernhard GmbH\nOoser Hauptstr. 51\n76532 Baden-Baden", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@gedaempft']]),
|
||||
$text($praefix.'-kontakt-route', '<a href="https://www.google.com/maps/search/?api=1&query=Elektro+Bernhard+Ooser+Hauptstr.+51+Baden-Baden" target="_blank">Route auf Google Maps →</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'color' => '@rot', 'margin' => ['top' => '10px']]]),
|
||||
]),
|
||||
$column($praefix.'-kontakt-zeiten', [
|
||||
$heading($praefix.'-kontakt-zeiten-titel', 'Bürozeiten', 3, ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'margin' => ['bottom' => '8px']]]),
|
||||
$text($praefix.'-kontakt-zeiten-text', "Mo–Fr: 07:30–17:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@gedaempft']]),
|
||||
$text($praefix.'-kontakt-zeiten-notdienst', '<strong>Notdienst: 24/7 erreichbar</strong>', ['desktop' => ['fontSize' => '15px', 'margin' => ['top' => '10px']]]),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px'], 'margin' => ['top' => '0px']]]),
|
||||
]),
|
||||
$fx($column($praefix.'-kontakt-rechts', [
|
||||
$heading($praefix.'-formular-titel', 'Schreiben Sie uns', 3, $h3Stil),
|
||||
$text($praefix.'-formular-hinweis', 'Für einen Notfall nutzen Sie bitte die Notdienstnummer.', ['desktop' => ['fontSize' => '14px', 'color' => '@gedaempft', 'margin' => ['bottom' => '22px']]]),
|
||||
['id' => $praefix.'-formular', 'type' => 'form', 'props' => ['form' => 'kontakt']],
|
||||
], ['desktop' => ['backgroundColor' => '@papier', 'padding' => $alleSeiten('40px'), 'borderRadius' => $alleEcken('20px')], 'mobile' => ['padding' => $alleSeiten('24px')]]), 'rise', 'short', null, 'strong'),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['backgroundColor' => '@weich']], 'kontakt');
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Elektro Bernhard GmbH',
|
||||
'description' => 'Elektroinstallation, Smart Home und 24h-Notdienst in Baden-Baden, Rastatt und Umgebung. Über 35 Jahre Erfahrung. Jetzt Elektro Bernhard kontaktieren.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: das Teamfoto als Hintergrund, dunkel überlagert, weißer Titel.
|
||||
$section('hero', [
|
||||
$column('hero-text', [
|
||||
$fx($eyebrow('hero-label', 'Elektro Bernhard GmbH · Seit über 35 Jahren', '#ff7d90'), 'fade'),
|
||||
$fx($heading('hero-titel', "Ihr Elektriker in\nBaden-Baden.", 1, ['desktop' => ['fontSize' => '76px', 'lineHeight' => 1.04, 'letterSpacing' => '-0.035em', 'fontWeight' => '900', 'color' => '@papier', 'margin' => ['bottom' => '24px']], 'tablet' => ['fontSize' => '56px'], 'mobile' => ['fontSize' => '40px']]), 'rise', 'short'),
|
||||
$fx($text('hero-intro', 'Ob Neubau, Sanierung oder eine Störung an der Elektrik: Wir kümmern uns darum. Für Privatkunden und Betriebe von Karlsruhe bis Offenburg.', ['desktop' => ['color' => '#ffffffd9', 'fontSize' => '20px', 'lineHeight' => 1.65, 'maxWidth' => '600px', 'margin' => ['bottom' => '34px']], 'mobile' => ['fontSize' => '17px']]), 'rise', 'medium'),
|
||||
$fx($row('hero-aktionen', [
|
||||
$column('hero-aktion-anfrage', [$button('hero-anfrage', 'Anfrage stellen', '/kontakt')]),
|
||||
$column('hero-aktion-telefon', [
|
||||
$kontaktBlock('hero-telefon', ['phone' => $buero], ['desktop' => [
|
||||
'color' => '@papier', 'fontSize' => '15px', 'fontWeight' => '700', 'minHeight' => '54px', 'alignItems' => 'center', 'justifyContent' => 'center',
|
||||
'padding' => ['top' => '14px', 'right' => '24px', 'bottom' => '14px', 'left' => '20px'],
|
||||
'borderWidth' => $alleSeiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#ffffff66', 'borderRadius' => $alleEcken('8px'),
|
||||
'hover' => ['backgroundColor' => '#ffffff1f', 'borderColor' => '@papier'],
|
||||
]]),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'auto', 'gap' => 'small'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]), 'rise', 'long'),
|
||||
$fx($row('hero-zahlen', [
|
||||
['id' => 'hero-zahl-jahre', 'type' => 'stat', 'props' => ['value' => '35+', 'label' => 'Jahre Erfahrung']],
|
||||
['id' => 'hero-zahl-team', 'type' => 'stat', 'props' => ['value' => '11', 'label' => 'Mitarbeiter im Team']],
|
||||
['id' => 'hero-zahl-notdienst', 'type' => 'stat', 'props' => ['value' => '24h', 'label' => 'Notdienst erreichbar']],
|
||||
], ['desktop' => ['columns' => 'three', 'gap' => 'medium'], 'mobile' => ['columns' => 'three', 'gap' => 'small']], ['desktop' => ['margin' => ['top' => '56px'], 'padding' => ['top' => '28px'], 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '#ffffff2e', 'color' => '@papier', 'fontSize' => '36px', 'maxWidth' => '640px'], 'mobile' => ['fontSize' => '24px', 'margin' => ['top' => '36px']]]), 'fade', 'long'),
|
||||
], ['desktop' => ['maxWidth' => '780px']]),
|
||||
], [
|
||||
'desktop' => ['backgroundImage' => $medien['team']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', 'overlayColor' => '#0e1013', 'overlayOpacity' => 0.68,
|
||||
'color' => '@papier', 'minHeight' => '92dvh', 'padding' => ['top' => '230px', 'bottom' => '100px'], 'display' => 'flex', 'alignItems' => 'center'],
|
||||
'mobile' => ['padding' => ['top' => '176px', 'bottom' => '64px'], 'minHeight' => 'auto'],
|
||||
]),
|
||||
|
||||
// 2 – Leistungen: vier Karten
|
||||
$section('leistungen', [
|
||||
$row('leistungen-kopf', [
|
||||
$column('leistungen-kopf-titel', [
|
||||
$fx($eyebrow('leistungen-label', 'Unsere Leistungen'), 'fade'),
|
||||
$fx($heading('leistungen-titel', 'Elektrotechnik aus einer Hand', 2, $h2Stil), 'rise'),
|
||||
]),
|
||||
$column('leistungen-kopf-text', [
|
||||
$fx($text('leistungen-intro', 'Wir übernehmen Beratung, Planung und Installation. Auch nach Abschluss der Arbeiten erreichen Sie uns bei Fragen oder Störungen.', ['desktop' => [...$fliess['desktop'], 'maxWidth' => '460px']]), 'rise', 'short'),
|
||||
], ['desktop' => ['alignSelf' => 'flex-end']]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px', 'bottom' => '40px']]]),
|
||||
$row('leistungen-raster', array_map(
|
||||
static fn (array $l, int $i): array => [...$karte('leistung-'.$l[0], $l[1], $l[2], $l[3], '/leistungen/'.$l[0]), 'layout' => ['effects' => ['shadow' => 'soft', 'hover' => 'lift', 'entrance' => 'rise', 'delay' => ['none', 'short', 'medium', 'long'][$i]]]],
|
||||
$leistungen, array_keys($leistungen),
|
||||
), ['desktop' => ['columns' => 'four', 'gap' => 'medium'], 'tablet' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], [], 'service'),
|
||||
|
||||
// 3 – Über uns: Foto mit Schatten, daneben Text und die Stärken
|
||||
$section('unternehmen', [
|
||||
$row('ueber-uns-zeile', [
|
||||
$column('ueber-uns-bild-spalte', [
|
||||
$fx($bild('ueber-uns-bild', $medien['schaltschrank'], 'Elektrofachkraft bei der Arbeit an einem Schaltschrank', ['desktop' => ['imageHeight' => '560px', 'objectFit' => 'cover', 'imageRadius' => $alleEcken('20px')], 'mobile' => ['imageHeight' => '340px']]), 'left', 'none', null, 'strong'),
|
||||
], ['desktop' => ['alignSelf' => 'center']]),
|
||||
$column('ueber-uns-text', [
|
||||
$fx($eyebrow('ueber-uns-label', 'Über uns'), 'fade'),
|
||||
$fx($heading('ueber-uns-titel', 'Ein Betrieb mit Tradition – und moderner Technik', 2, $h2Stil), 'rise'),
|
||||
$fx($text('ueber-uns-1', 'Elektro Bernhard kommt ursprünglich aus Hügelsheim. Heute finden Sie uns in der Ooser Hauptstraße 51 in Baden-Baden. Elektroinstallation und Kundenservice gehören seit über 35 Jahren zu unserer Arbeit; 2017 wurde der Betrieb modernisiert – seitdem mit dem Schwerpunkt auf Gebäudetechnik und Smart Home.', $fliess), 'rise', 'short'),
|
||||
$fx($row('staerken-raster', array_map(
|
||||
static fn (array $s): array => $punkt('staerke-'.$s[0], $s[1], $s[2], $s[3]),
|
||||
array_slice($staerken, 0, 4),
|
||||
), ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '10px']]]), 'rise', 'medium'),
|
||||
$fx($text('ueber-uns-link', '<a href="/leistungen">Mehr über unsere Arbeit →</a>', $linkStil), 'fade', 'long'),
|
||||
], ['desktop' => ['alignSelf' => 'center']]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['backgroundColor' => '@weich']], 'ueber-uns'),
|
||||
|
||||
// 4 – Kundenstimmen als Slider
|
||||
$section('stimmen', [
|
||||
$row('stimmen-kopf', [
|
||||
$column('stimmen-kopf-titel', [
|
||||
$fx($eyebrow('stimmen-label', 'Google-Bewertungen'), 'fade'),
|
||||
$fx($heading('stimmen-titel', 'Was unsere Kunden sagen', 2, $h2Stil), 'rise'),
|
||||
]),
|
||||
$column('stimmen-kopf-link', [
|
||||
$fx($button('stimmen-link', 'Auf Google bewerten', 'https://g.page/ElektroBernhardbadenbaden', ['desktop' => ['margin' => ['top' => '0px']]], ['desktop' => ['appearance' => 'outline']]), 'fade', 'short'),
|
||||
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'flex-end', 'alignItems' => 'flex-end'], 'mobile' => ['justifyContent' => 'flex-start']]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px', 'bottom' => '36px']]]),
|
||||
['id' => 'stimmen-slider', 'type' => 'slider', 'props' => ['desktopVisible' => 3, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true, 'dots' => true, 'autoplay' => 7],
|
||||
'children' => array_map(static function (array $s) use ($alleEcken, $alleSeiten): array {
|
||||
return [
|
||||
'id' => 'stimme-'.$s[0], 'type' => 'quote',
|
||||
'props' => ['text' => $s[1], 'author' => $s[2], 'role' => $s[3], 'rating' => 5],
|
||||
'style' => ['desktop' => ['backgroundColor' => '@papier', 'padding' => $alleSeiten('30px'), 'borderWidth' => $alleSeiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'borderRadius' => $alleEcken('16px'), 'fontSize' => '16px', 'height' => '100%']],
|
||||
'css' => "& .quote-rating{color:#e8a300;letter-spacing:3px;font-size:15px}\n& figcaption span{display:block;color:#5d636b;font-weight:400;font-size:13px;margin-top:4px}",
|
||||
'layout' => ['effects' => ['shadow' => 'soft']],
|
||||
];
|
||||
}, $stimmen)],
|
||||
$text('stimmen-hinweis', 'Ausgewählte Google-Rezensionen aus 2024. Stand der Auswahl: 14.09.2026. Es wird kein Gesamtdurchschnitt ausgewiesen.', ['desktop' => ['fontSize' => '13px', 'color' => '@gedaempft', 'margin' => ['top' => '24px']]]),
|
||||
]),
|
||||
|
||||
// 5 – Marken
|
||||
$section('marken', [
|
||||
$row('marken-zeile', array_merge(
|
||||
[$column('marken-label-spalte', [$text('marken-label', 'Wir arbeiten mit bewährten Marken', ['desktop' => ['color' => '@gedaempft', 'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.12em', 'textTransform' => 'uppercase']])])],
|
||||
array_map(static fn (string $name, int $i): array => [
|
||||
'id' => 'marke-'.($i + 1), 'type' => 'column',
|
||||
'children' => [['id' => 'marke-name-'.($i + 1), 'type' => 'text', 'props' => ['text' => $name], 'style' => ['desktop' => ['fontSize' => '24px', 'fontWeight' => '900', 'letterSpacing' => '-0.04em', 'color' => '#6b7178', 'textAlign' => 'center'], 'mobile' => ['fontSize' => '20px']]]],
|
||||
], $marken, array_keys($marken)),
|
||||
), [], ['desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'alignItems' => 'center', 'justifyContent' => 'space-between', 'gap' => '40px', 'margin' => ['top' => '0px']], 'mobile' => ['gap' => '22px', 'justifyContent' => 'center']]),
|
||||
], ['desktop' => ['padding' => ['top' => '40px', 'bottom' => '40px'], 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie'], 'mobile' => ['padding' => ['top' => '32px', 'bottom' => '32px']]]),
|
||||
|
||||
// 6 – Solar: dunkles Band
|
||||
$section('energie', [
|
||||
$row('energie-zeile', [
|
||||
$column('energie-text', [
|
||||
$fx($eyebrow('energie-label', 'Regenerative Energie', '#ff7d90'), 'fade'),
|
||||
$fx($heading('energie-titel', 'Sie planen eine Solaranlage?', 2, ['desktop' => [...$h2Stil['desktop'], 'color' => '@papier'], 'mobile' => $h2Stil['mobile']]), 'rise'),
|
||||
$fx($text('energie-intro', 'Solaranlagen für Ihr Zuhause oder Geschäftsgebäude – mit der eigenständigen Elektro Bernhard Regenerative Energie GmbH.', $fliessHell), 'rise', 'short'),
|
||||
$fx($button('energie-knopf', 'Mehr über Solarenergie', '/regenerative-energie', ['desktop' => ['margin' => ['top' => '12px']]]), 'rise', 'medium'),
|
||||
], ['desktop' => ['alignSelf' => 'center']]),
|
||||
$fx($column('energie-liste', [
|
||||
$punkt('energie-punkt-1', 'check', 'Individuelle Solarplanung', 'Auslegung nach Dach, Verbrauch und Ziel.', '#ff7d90', '#c2c6cc'),
|
||||
$punkt('energie-punkt-2', 'check', 'Fachgerechte Installation', 'Montage und Anschluss aus einer Hand.', '#ff7d90', '#c2c6cc'),
|
||||
$punkt('energie-punkt-3', 'check', 'Angebot für Ihr Gebäude', 'Transparent und auf Ihr Vorhaben zugeschnitten.', '#ff7d90', '#c2c6cc'),
|
||||
], ['desktop' => ['backgroundColor' => '@karte-dunkel', 'color' => '@papier', 'padding' => $alleSeiten('36px'), 'borderRadius' => $alleEcken('20px'), 'display' => 'flex', 'flexDirection' => 'column', 'gap' => '22px']]), 'right', 'short'),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier']]),
|
||||
|
||||
// 7 – Häufige Fragen
|
||||
$section('fragen', [
|
||||
$row('fragen-zeile', [
|
||||
$column('fragen-intro', [
|
||||
$fx($eyebrow('fragen-label', 'FAQ'), 'fade'),
|
||||
$fx($heading('fragen-titel', 'Häufige Fragen', 2, $h2Stil), 'rise'),
|
||||
$fx($text('fragen-text', 'Wenn Ihre Frage hier nicht dabei ist, rufen Sie uns gerne an.', ['desktop' => [...$fliess['desktop'], 'maxWidth' => '360px']]), 'rise', 'short'),
|
||||
$fx($kontaktBlock('fragen-telefon', ['phone' => $buero], ['desktop' => ['fontSize' => '18px', 'fontWeight' => '900', 'color' => '@rot', 'margin' => ['top' => '10px']]]), 'rise', 'medium'),
|
||||
]),
|
||||
$column('fragen-liste', [
|
||||
['id' => 'fragen-akkordeon', 'type' => 'accordion', 'props' => ['faq' => true], 'children' => array_map(static function (array $f, int $i) use ($alleEcken): array {
|
||||
return [
|
||||
'id' => 'frage-'.($i + 1), 'type' => 'accordionitem', 'props' => ['title' => $f[0], 'open' => 0 === $i],
|
||||
'style' => ['desktop' => ['backgroundColor' => '@weich', 'borderStyle' => 'none', 'borderRadius' => $alleEcken('14px'), 'padding' => ['top' => '4px', 'right' => '24px', 'bottom' => '4px', 'left' => '24px']]],
|
||||
'css' => "& > summary{font-size:17px;padding:18px 0}\n& > summary::after{color:#c8102e}",
|
||||
'children' => [['id' => 'antwort-'.($i + 1), 'type' => 'text', 'props' => ['text' => $f[1]], 'style' => ['desktop' => ['fontSize' => '15px', 'color' => '@gedaempft', 'lineHeight' => 1.75, 'padding' => ['bottom' => '14px']]]]],
|
||||
];
|
||||
}, $fragen, array_keys($fragen)), 'style' => ['desktop' => ['gap' => '12px']]],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
]),
|
||||
|
||||
// 8 – Kontakt
|
||||
$kontaktAbschnitt('start'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten anlegen und veröffentlichen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$erstellt = [];
|
||||
$anlegen = static function (string $slug, ?string $elternId, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db, &$erstellt): string {
|
||||
$id = $pages->createPage($slug, $elternId, 'page', $dokument);
|
||||
// Der Dokumenttitel ist der kurze Name (Brotkrumen, Menüs); der lange steht in „Titel für die Suche“.
|
||||
if ('' !== $seoTitel) {
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
|
||||
}
|
||||
// Das Bild beim Teilen; im Editor unter „Vorschaubild beim Teilen“.
|
||||
if ('' !== $teilbild) {
|
||||
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
|
||||
}
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
$erstellt[$slug] = $id;
|
||||
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
$anlegen('home', null, $startseite, 'Elektriker Baden-Baden | Elektro Bernhard GmbH', $medien['team']['id']);
|
||||
|
||||
// Die Leistungsübersicht: Pfade wie /leistungen/smart-home brauchen im CMS eine Elternseite.
|
||||
$leistungenId = $anlegen('leistungen', null, [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Leistungen',
|
||||
'description' => 'Elektroinstallation, Smart Home, 24h-Notdienst sowie Beratung und Planung von Elektro Bernhard in Baden-Baden.',
|
||||
'blocks' => [
|
||||
$seitenkopf('leistungen-seite', 'Unsere Leistungen', 'Elektrotechnik aus einer Hand', 'Wir übernehmen Beratung, Planung und Installation. Auch nach Abschluss der Arbeiten erreichen Sie uns bei Fragen oder Störungen.'),
|
||||
$section('leistungen-seite-raster-abschnitt', [
|
||||
$fx($heading('leistungen-seite-h2', 'Vier Leistungen, ein Ansprechpartner', 2, ['desktop' => [...$h2Stil['desktop'], 'margin' => ['bottom' => '36px']], 'mobile' => $h2Stil['mobile']]), 'rise'),
|
||||
$row('leistungen-seite-raster', array_map(
|
||||
static fn (array $l, int $i): array => [...$karte('leistungen-seite-'.$l[0], $l[1], $l[2], $l[3], '/leistungen/'.$l[0]), 'layout' => ['effects' => ['shadow' => 'soft', 'hover' => 'lift', 'entrance' => 'rise', 'delay' => ['none', 'short', 'medium', 'long'][$i]]]],
|
||||
$leistungen, array_keys($leistungen),
|
||||
), ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
]),
|
||||
$section('leistungen-seite-staerken', [
|
||||
$fx($eyebrow('staerken-label', 'Wer wir sind'), 'fade'),
|
||||
$fx($heading('staerken-titel', 'Warum Elektro Bernhard', 2, $h2Stil), 'rise'),
|
||||
$fx($text('staerken-intro', 'Seit über 35 Jahren Elektroinstallation und Kundenservice – seit der Modernisierung 2017 mit dem Schwerpunkt auf moderner Gebäudetechnik.', ['desktop' => [...$fliess['desktop'], 'maxWidth' => '620px', 'margin' => ['bottom' => '30px']]]), 'rise', 'short'),
|
||||
$row('staerken-alle', array_map(static fn (array $s): array => $fx($punkt('staerke-seite-'.$s[0], $s[1], $s[2], $s[3]), 'rise', 'short'), $staerken),
|
||||
['desktop' => ['columns' => 'three', 'gap' => 'large'], 'tablet' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
$cta('leistungen-seite-cta'),
|
||||
], ['desktop' => ['backgroundColor' => '@weich']]),
|
||||
],
|
||||
], 'Leistungen | Elektro Bernhard GmbH', $medien['schaltschrank']['id']);
|
||||
|
||||
foreach ($leistungen as [$slug, $icon, $name, $kurz]) {
|
||||
[$titel, $h1, $intro, $absaetze] = $leistungstexte[$slug];
|
||||
$knopf = 'notdienst' === $slug
|
||||
? $kontaktBlock($slug.'-notdienst-kontakt', ['phone' => $notdienst], ['desktop' => ['fontSize' => '30px', 'fontWeight' => '900', 'color' => '@papier', 'margin' => ['top' => '24px']]])
|
||||
: $button($slug.'-anfrage', 'Projekt anfragen', '/kontakt', ['desktop' => ['margin' => ['top' => '20px']]]);
|
||||
$anlegen($slug, $leistungenId, [
|
||||
'schemaVersion' => $SCHEMA, 'title' => $name, 'description' => $intro,
|
||||
'blocks' => [
|
||||
$seitenkopf($slug, $name, $h1, $intro, [$knopf]),
|
||||
$textkoerper($slug, array_merge($absaetze, [
|
||||
['Ihr Elektriker vor Ort', 'Elektro Bernhard GmbH · Ooser Hauptstr. 51 · 76532 Baden-Baden. Für Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach und das Murgtal, Ettlingen und Karlsruhe sowie Achern, Oberkirch und Offenburg.'],
|
||||
]), [], [
|
||||
$text($slug.'-zurueck', '<a href="/leistungen">Alle Leistungen ansehen →</a>', ['desktop' => [...$linkStil['desktop'], 'margin' => ['top' => '30px']]]),
|
||||
$cta($slug.'-cta'),
|
||||
]),
|
||||
],
|
||||
], $titel, $medien['schaltschrank']['id']);
|
||||
}
|
||||
|
||||
$anlegen('kontakt', null, [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kontakt & Anfahrt',
|
||||
'description' => 'Elektro Bernhard: Ooser Hauptstr. 51, Baden-Baden. Telefon '.$buero.'. Projekt anfragen oder 24h-Notdienst unter '.$notdienst.' erreichen.',
|
||||
'blocks' => [
|
||||
$seitenkopf('kontakt-seite', 'Kontakt & Anfahrt', 'Kontakt zu Elektro Bernhard', 'Schreiben Sie uns eine Nachricht oder rufen Sie direkt an – wir melden uns innerhalb von 24 Stunden.'),
|
||||
$kontaktAbschnitt('seite'),
|
||||
],
|
||||
], 'Kontakt & Anfahrt | Elektro Bernhard Baden-Baden', $medien['team']['id']);
|
||||
|
||||
$anlegen('regenerative-energie', null, [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Solarenergie',
|
||||
'description' => 'Solaranlagen für Wohnhäuser und Geschäftsgebäude: Planung, Installation und individuelles Angebot der Elektro Bernhard Regenerative Energie GmbH.',
|
||||
'blocks' => [
|
||||
$seitenkopf('solar', 'Elektro Bernhard Regenerative Energie GmbH', 'Solaranlagen planen und installieren', 'Solarplanung und Installation für Ihr Zuhause oder Geschäftsgebäude.', [$button('solar-knopf', 'Solaranlage anfragen', '/kontakt', ['desktop' => ['margin' => ['top' => '20px']]])]),
|
||||
$textkoerper('solar', [
|
||||
['Für Wohnhäuser und Geschäftsgebäude', 'Jedes Gebäude bringt andere Voraussetzungen mit. Die Elektro Bernhard Regenerative Energie GmbH plant und installiert Solaranlagen und erstellt ein maßgeschneidertes Angebot für Ihr Vorhaben.'],
|
||||
['Planung und Installation', 'Beschreiben Sie uns Ihr Gebäude und Ihre Vorstellungen. Auf dieser Grundlage besprechen wir die nächsten Schritte für Ihre Solarplanung.'],
|
||||
['Ihr Ansprechpartner', "<strong>Elektro Bernhard Regenerative Energie GmbH</strong><br>Ooser Hauptstr. 51 · 76532 Baden-Baden<br>Geschäftsführer: Ridvan Kantarci · HRB 741275"],
|
||||
['', 'Die Gesellschaft ist rechtlich eigenständig und unabhängig von der Elektro Bernhard GmbH.'],
|
||||
], [], [
|
||||
$text('solar-kontaktlink', '<a href="/kontakt">Kontakt zur Solarplanung aufnehmen →</a>', ['desktop' => [...$linkStil['desktop'], 'margin' => ['top' => '30px']]]),
|
||||
$cta('solar-cta'),
|
||||
]),
|
||||
],
|
||||
], 'Solarplanung & Installation | Elektro Bernhard Regenerative Energie', $medien['team']['id']);
|
||||
|
||||
$anlegen('impressum', null, [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben und Kontaktinformationen der Elektro Bernhard GmbH und der Elektro Bernhard Regenerative Energie GmbH.',
|
||||
'blocks' => [
|
||||
$seitenkopf('impressum', 'Rechtliche Informationen', 'Impressum'),
|
||||
$textkoerper('impressum', [
|
||||
['Elektro Bernhard GmbH', "Ooser Hauptstr. 51<br>76532 Baden-Baden<br><br>Vertreten durch den Geschäftsführer Ridvan Kantarci.<br><br>Telefon: <a href=\"tel:+4972213944909\">+49 7221 3944909</a><br>E-Mail: <a href=\"mailto:$mail\">$mail</a><br><br>Handelsregister: HRB 731961<br>Umsatzsteuer-Identifikationsnummer: DE 815 618 082"],
|
||||
['Elektro Bernhard Regenerative Energie GmbH', "Eigenständige Gesellschaft, unabhängig von der Elektro Bernhard GmbH.<br>Ooser Hauptstr. 51<br>76532 Baden-Baden<br>Vertreten durch Ridvan Kantarci.<br>Handelsregister: HRB 741275"],
|
||||
['Gestaltung und Umsetzung', '<a href="https://www.kant-it.de/">Kant-IT Solutions</a>'],
|
||||
['Bildnachweise', 'Logo, Teamfoto und Elektroinstallationsmotiv aus dem bestehenden Webauftritt von Elektro Bernhard. Markenbezeichnungen gehören ihren jeweiligen Inhabern.'],
|
||||
]),
|
||||
],
|
||||
], 'Impressum | Elektro Bernhard GmbH');
|
||||
|
||||
$anlegen('datenschutzerklaerung', null, [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutzhinweise',
|
||||
'description' => 'Informationen zur Verarbeitung personenbezogener Daten bei Elektro Bernhard: Websitebesuch, Kontaktaufnahme und Ihre Rechte.',
|
||||
'blocks' => [
|
||||
$seitenkopf('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, wie wir Daten beim Besuch unserer Website und bei Ihrer Kontaktaufnahme verarbeiten.'),
|
||||
$textkoerper('datenschutz', [
|
||||
['1. Verantwortlicher', "Elektro Bernhard GmbH, Ooser Hauptstr. 51, 76532 Baden-Baden. Geschäftsführer: Ridvan Kantarci. Telefon: +49 7221 3944909. E-Mail: <a href=\"mailto:$mail\">$mail</a>."],
|
||||
['2. Besuch der Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten, insbesondere IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserinformationen und HTTP-Status. Das dient der sicheren Bereitstellung und Fehleranalyse. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Umfang und Dauer der Serverprotokollierung richten sich nach der eingesetzten Hostingkonfiguration.'],
|
||||
['3. Lokale Inhalte und Cookies', 'Die Website selbst setzt keine Analyse- oder Werbecookies ein. Bilder, Schriften und Gestaltung werden vom eigenen Webserver geladen. Es werden keine Trackingdienste oder Karten beim Seitenaufruf eingebettet.'],
|
||||
['4. Kontaktanfragen', 'Wenn Sie uns kontaktieren, verarbeiten wir die von Ihnen angegebenen Kontakt- und Inhaltsdaten zur Bearbeitung Ihrer Anfrage. Rechtsgrundlage ist bei vertragsbezogenen Anfragen Art. 6 Abs. 1 lit. b DSGVO, ansonsten unser berechtigtes Interesse an der Bearbeitung von Anfragen gemäß Art. 6 Abs. 1 lit. f DSGVO.<br><br>Das Kontaktformular dieser Website überträgt Ihre Angaben an unseren Server und versendet sie als E-Mail an uns. NOCH ZU PRÜFEN: Hostinganbieter, Verarbeitungsländer, Auftragsverarbeitung und konkrete Aufbewahrungsdauer ergänzen.<br><br>Wir löschen Anfragedaten, wenn sie für die Bearbeitung nicht mehr erforderlich sind und keine gesetzlichen Aufbewahrungspflichten oder berechtigten Gründe für eine weitere Speicherung bestehen.'],
|
||||
['5. Externe Links', 'Google Maps, Google-Bewertungen und unsere Profile in sozialen Netzwerken sind als Links eingebunden. Erst beim Öffnen eines solchen Links wird die jeweilige externe Website aufgerufen. Dort gelten die Datenschutzinformationen des betreffenden Anbieters.'],
|
||||
['6. Ihre Rechte', "Unter den gesetzlichen Voraussetzungen haben Sie Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Sie können einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO widersprechen. Eine erteilte Einwilligung können Sie mit Wirkung für die Zukunft widerrufen.<br><br>Außerdem können Sie sich bei einer Datenschutzaufsichtsbehörde beschweren, beispielsweise beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.<br><br>Für Fragen wenden Sie sich bitte an <a href=\"mailto:$mail\">$mail</a>."],
|
||||
]),
|
||||
],
|
||||
], 'Datenschutz | Elektro Bernhard GmbH');
|
||||
|
||||
$fehlerId = $anlegen('404', null, [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
|
||||
'description' => 'Die gesuchte Seite existiert nicht oder wurde verschoben. Über die Startseite finden Sie zurück.',
|
||||
'blocks' => [
|
||||
$seitenkopf('fehler', 'Fehler 404', 'Seite nicht gefunden', 'Die gesuchte Seite existiert nicht oder wurde verschoben.', [$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '20px']]])]),
|
||||
],
|
||||
]);
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Menüs, Einstellungen, Weiterleitungen, Konto
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$seite = static fn (string $label, string $slug, string $anchor = ''): array => ['label' => $label, 'type' => 'page', 'pageId' => $erstellt[$slug], 'anchor' => $anchor, 'children' => []];
|
||||
|
||||
$menus->save('header', 'Hauptnavigation', [
|
||||
$seite('Leistungen', 'leistungen'),
|
||||
$seite('Über uns', 'home', 'ueber-uns'),
|
||||
$seite('Solarenergie', 'regenerative-energie'),
|
||||
$seite('Kontakt', 'kontakt'),
|
||||
], 0);
|
||||
$menus->save('secondary', 'Notdienst', [
|
||||
['label' => '24h Notdienst '.$notdienst, 'type' => 'url', 'url' => 'tel:+4915125245851', 'icon' => 'phone', 'iconPosition' => 'before', 'children' => []],
|
||||
], 0);
|
||||
$menus->save('footer', 'Fußzeile', [
|
||||
$seite('Unsere Leistungen', 'leistungen'),
|
||||
$seite('Das Unternehmen', 'home', 'ueber-uns'),
|
||||
$seite('Regenerative Energie', 'regenerative-energie'),
|
||||
$seite('Kontakt & Anfahrt', 'kontakt'),
|
||||
], 0);
|
||||
$menus->save('legal', 'Rechtliches', [$seite('Impressum', 'impressum'), $seite('Datenschutz', 'datenschutzerklaerung')], 0);
|
||||
echo "Menüs angelegt\n";
|
||||
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => 'Elektro Bernhard GmbH',
|
||||
'description' => 'Elektrofachbetrieb in Baden-Baden, seit über 35 Jahren. Elektroinstallation für Neu- und Altbau, Smart Home, Beratung und Planung sowie ein 24h-Notdienst unter 0151 25245851. Bürozeiten Mo–Fr 7:30–17:00 Uhr.',
|
||||
'phone' => $buero, 'email' => $mail,
|
||||
'street' => 'Ooser Hauptstr. 51', 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'DE',
|
||||
// Die Profile, die der bestehende Auftritt selbst verlinkt - einmal hier, gezeigt vom Baustein
|
||||
// „Soziale Profile“ in Kopfleiste und Fuß, geführt als sameAs in den strukturierten Daten.
|
||||
'social' => [
|
||||
'facebook' => 'https://www.facebook.com/elektrobernhardgmbh',
|
||||
'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/',
|
||||
'google' => 'https://g.page/ElektroBernhardbadenbaden',
|
||||
],
|
||||
'hours' => array_fill_keys(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], ['opens' => '07:30', 'closes' => '17:00']),
|
||||
'areaServed' => implode("\n", ['Baden-Baden', 'Sinzheim', 'Hügelsheim', 'Iffezheim', 'Bühl', 'Rastatt', 'Kuppenheim', 'Muggensturm', 'Gaggenau', 'Gernsbach', 'Murgtal', 'Ettlingen', 'Karlsruhe', 'Achern', 'Oberkirch', 'Offenburg']),
|
||||
'localBusiness' => true, 'llmsTxt' => true,
|
||||
// Der Kopfbereich schwebt über dem dunklen Seitenkopf und bleibt beim Scrollen oben.
|
||||
'stickyHeader' => true, 'headerOverlay' => true,
|
||||
'motion' => 'always',
|
||||
'homePath' => 'home', 'privacyPath' => 'datenschutzerklaerung', 'imprintPath' => 'impressum',
|
||||
'consentBanner' => false, 'scrollTop' => true, 'scrollTopColor' => '@rot', 'scrollTopInk' => '@papier', 'postMetaHeader' => true,
|
||||
]);
|
||||
echo "Einstellungen gespeichert\n";
|
||||
|
||||
foreach (['/datenschutz' => '/datenschutzerklaerung', '/service' => '/leistungen', '/ueber-uns' => '/home'] as $von => $nach) {
|
||||
$redirects->save($von, $nach, 301);
|
||||
}
|
||||
echo "Weiterleitungen angelegt\n";
|
||||
|
||||
$passwort = bin2hex(random_bytes(12));
|
||||
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
|
||||
|
||||
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
|
||||
@@ -0,0 +1,909 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut die Website „Tagespflege Jacqueline Moldovan“ als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Vorlage ist das gelieferte Paket „Tagespflege_Moldovan_Website.zip“ (09.09.2026): ein Onepager
|
||||
* aus index.html, style.css und script.js. Wortlaut, Farben, Schriftgrößen, Abstände und die
|
||||
* Reihenfolge der Abschnitte sind daraus übernommen. Gebaut ist alles aus gewöhnlichen
|
||||
* Bausteinen – kein eigener Baustein, kein eigenes Skript, keine externe Schrift.
|
||||
*
|
||||
* Zwei Stellen weichen bewusst ab:
|
||||
* - Das Kontaktformular verschickt serverseitig, statt im Mailprogramm des Besuchers eine
|
||||
* vorbereitete E-Mail zu öffnen. Der Hinweistext darunter sagt das entsprechend.
|
||||
* - Das Markenzeichen liegt in der Vorlage als SVG vor; die Medienverwaltung nimmt JPEG, PNG und
|
||||
* WebP. Verwendet wird deshalb marke.png, die verlustfreie Umsetzung derselben Zeichnung.
|
||||
*
|
||||
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
|
||||
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-tagespflege-moldovan.sh).
|
||||
* Erstes Argument: der Ordner mit gemeinsam.webp, jacqueline-moldovan.png und marke.png.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$assets = $argv[1] ?? null;
|
||||
if (null === $assets || !is_dir($assets)) {
|
||||
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
|
||||
exit(1);
|
||||
}
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
|
||||
if (!str_contains($url, 'tagespflege-moldovan')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die Moldovan-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
|
||||
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$media = new MediaLibrary($db, $mediaDirectory);
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), $settings);
|
||||
$partials = new PartialRepository($db, new DocumentValidator());
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
$forms = new FormRepository($db);
|
||||
$theme = new Theme($db);
|
||||
|
||||
$mail = 'info@tagespflege-moldovan.com';
|
||||
$telefon = '07221 / 922 7961';
|
||||
$telHref = 'tel:+4972219227961';
|
||||
$SCHEMA = 23;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Bausteine
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$ecken = static fn (string $r): array => ['topLeft' => $r, 'topRight' => $r, 'bottomRight' => $r, 'bottomLeft' => $r];
|
||||
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
|
||||
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
$nurLinks = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $w];
|
||||
|
||||
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null): array {
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Der Rhythmus der Vorlage: 100 px am Schreibtisch, 75 auf dem Tablet, 61 auf dem Telefon. */
|
||||
$abschnitt = static function (string $id, array $children, array $style = [], ?string $anchor = null) use ($section): array {
|
||||
$basis = [
|
||||
'desktop' => ['padding' => ['top' => '100px', 'bottom' => '100px']],
|
||||
'tablet' => ['padding' => ['top' => '75px', 'bottom' => '75px']],
|
||||
'mobile' => ['padding' => ['top' => '61px', 'bottom' => '61px']],
|
||||
];
|
||||
foreach ($style as $device => $values) {
|
||||
$basis[$device] = [...($basis[$device] ?? []), ...$values];
|
||||
}
|
||||
|
||||
return $section($id, $children, $basis, $anchor);
|
||||
};
|
||||
|
||||
$row = static function (string $id, array $children, array $design = [], array $style = []): array {
|
||||
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
|
||||
if ([] !== $design) {
|
||||
$block['layout'] = ['design' => $design];
|
||||
}
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Eine Zeile, die nebeneinander stellt statt zu rastern – Marke, Signatur, Wertepunkt, Zeitleiste. */
|
||||
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = []) use ($row): array {
|
||||
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, 'margin' => ['top' => '0px'], ...$extra]];
|
||||
if ([] !== $tablet) {
|
||||
$style['tablet'] = $tablet;
|
||||
}
|
||||
if ([] !== $mobil) {
|
||||
$style['mobile'] = $mobil;
|
||||
}
|
||||
|
||||
return $row($id, $children, [], $style);
|
||||
};
|
||||
|
||||
$column = static function (string $id, array $children, array $style = [], string $sichtbarkeit = 'all'): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('all' !== $sichtbarkeit) {
|
||||
$block['layout'] = ['visibility' => $sichtbarkeit];
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$text = static function (string $id, string $inhalt, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Eine reine Fläche: der Strich vor einer Kleinzeile, die Linie der Zeitleiste, die grüne Form. */
|
||||
$flaeche = static function (string $id, array $style, string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'spacer', 'props' => ['size' => 'small'], 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$button = static function (string $id, string $text, string $href, array $style = []): array {
|
||||
return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href], 'style' => $style];
|
||||
};
|
||||
|
||||
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = ''): array {
|
||||
$gross = end($eintrag['variants']);
|
||||
$props = [
|
||||
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
|
||||
'mediaId' => $eintrag['id'],
|
||||
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
|
||||
];
|
||||
if (null !== $href) {
|
||||
$props['href'] = $href;
|
||||
$props['click'] = 'link';
|
||||
}
|
||||
|
||||
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$symbol = static function (string $id, string $name, int $groesse, array $style = []): array {
|
||||
return ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
|
||||
};
|
||||
|
||||
/**
|
||||
* Setzt die Einblendungen nach einer Liste, statt sie über das ganze Dokument zu streuen.
|
||||
*
|
||||
* Ein Block mit Einblendung wartet unsichtbar, bis er ins Bild scrollt; was beim Laden schon
|
||||
* sichtbar ist, spielt sofort. Ohne JavaScript steht alles einfach da – die Seite bleibt
|
||||
* vollständig, nur ohne Bewegung.
|
||||
*
|
||||
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
|
||||
*/
|
||||
$einblenden = static function (array $dokument, array $plan): array {
|
||||
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
|
||||
foreach ($bloecke as $i => $block) {
|
||||
if (isset($plan[$block['id'] ?? ''])) {
|
||||
[$art, $verzug] = [...$plan[$block['id']], 'none'];
|
||||
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$bloecke[$i]['children'] = $gehen($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $bloecke;
|
||||
};
|
||||
$dokument['blocks'] = $gehen($dokument['blocks']);
|
||||
|
||||
return $dokument;
|
||||
};
|
||||
|
||||
// Wiederkehrende Gestaltungswerte der Vorlage.
|
||||
$serif = ['fontFamily' => 'heading', 'fontWeight' => '400', 'letterSpacing' => '-0.045em', 'lineHeight' => 1.1];
|
||||
$h2Stil = ['desktop' => [...$serif, 'fontSize' => '53px', 'margin' => ['bottom' => '0px']], 'tablet' => ['fontSize' => '42px'], 'mobile' => ['fontSize' => '38px']];
|
||||
$emGruen = '& em{color:#718f3d;font-weight:400}';
|
||||
$fliess = ['desktop' => ['color' => '@gedaempft', 'fontSize' => '16px', 'lineHeight' => 1.65, 'margin' => ['bottom' => '20px']]];
|
||||
$klein = ['desktop' => ['fontSize' => '11px', 'fontWeight' => '700', 'letterSpacing' => '0.13em', 'color' => '#6a795e', 'lineHeight' => 1.5, 'margin' => ['bottom' => '0px']]];
|
||||
$h3Stil = ['desktop' => ['fontFamily' => 'body', 'fontSize' => '19px', 'fontWeight' => '500', 'letterSpacing' => '-0.025em', 'lineHeight' => 1.35, 'margin' => ['bottom' => '13px']]];
|
||||
|
||||
/** Die Kleinzeile über einer Überschrift: ein kurzer Strich, dann Versalien. */
|
||||
$kleinzeile = static function (string $id, string $inhalt, string $farbe = '#527236', string $strich = '@gruen', string $abstand = '25px') use ($zeile, $flaeche, $text): array {
|
||||
return $zeile($id, [
|
||||
$flaeche($id.'-strich', ['desktop' => ['width' => '24px', 'height' => '3px', 'backgroundColor' => $strich, 'flexShrink' => 0]]),
|
||||
$text($id.'-text', $inhalt, [
|
||||
'desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.16em', 'textTransform' => 'uppercase', 'lineHeight' => 1.7, 'color' => $farbe, 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '11px'],
|
||||
]),
|
||||
], '11px', ['margin' => ['top' => '0px', 'bottom' => $abstand]], ['margin' => ['top' => '0px', 'bottom' => '18px']]);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien, Formular
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'Tagespflege Jacqueline Moldovan', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'tinte', 'name' => 'Tinte', 'value' => '#294655'],
|
||||
['key' => 'blau', 'name' => 'Blau', 'value' => '#2e7595'],
|
||||
['key' => 'blau-dunkel', 'name' => 'Blau dunkel', 'value' => '#245e79'],
|
||||
['key' => 'dunkelblau', 'name' => 'Kontaktbereich', 'value' => '#284f63'],
|
||||
['key' => 'gruen', 'name' => 'Grün', 'value' => '#9abf56'],
|
||||
['key' => 'limette', 'name' => 'Limette', 'value' => '#c0d348'],
|
||||
['key' => 'oliv', 'name' => 'Oliv', 'value' => '#718f3d'],
|
||||
['key' => 'marke', 'name' => 'Markengrün', 'value' => '#83a944'],
|
||||
['key' => 'gedaempft', 'name' => 'Gedämpft', 'value' => '#647078'],
|
||||
['key' => 'blass', 'name' => 'Blass', 'value' => '#f3f6ef'],
|
||||
['key' => 'leiste', 'name' => 'Kopfleiste', 'value' => '#f1f4ee'],
|
||||
['key' => 'sand', 'name' => 'Werteband', 'value' => '#fafbf8'],
|
||||
['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#dce3dd'],
|
||||
['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'],
|
||||
],
|
||||
[
|
||||
// Beide Schriften stehen auf jedem Gerät bereit; die Vorlage lädt bewusst keine Webschrift.
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Georgia', 'source' => 'system', 'weights' => [400], 'fallback' => 'serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.65, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@tinte', 'containerWidth' => '1240px', 'headingScale' => 1.25, 'linkUnderline' => false],
|
||||
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 15, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@blau', 'color' => '@papier', 'hoverBackgroundColor' => '@blau-dunkel', 'hoverColor' => '@papier'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'marke' => ['marke.png', 'Markenzeichen der Tagespflege Jacqueline Moldovan: ein Schmetterling in Grün und Blau'],
|
||||
'gemeinsam' => ['gemeinsam.webp', 'Illustrative Szene: Eine Seniorin und eine Begleiterin lachen gemeinsam an einem Tisch.'],
|
||||
'portraet' => ['jacqueline-moldovan.png', 'Jacqueline Moldovan'],
|
||||
] as $schluessel => [$datei, $alt]) {
|
||||
$pfad = rtrim($assets, '/\\').'/'.$datei;
|
||||
if (!is_file($pfad)) {
|
||||
fwrite(STDERR, "Bild fehlt: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$eintrag = $media->upload(new UploadedFile($pfad, $datei, null, null, true));
|
||||
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
|
||||
$medien[$schluessel] = $media->find($eintrag['id']);
|
||||
echo "Bild übernommen: $datei\n";
|
||||
}
|
||||
|
||||
$forms->save('kontakt', 'Kontaktanfrage', [
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Für einen persönlichen Rückruf'],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true, 'help' => 'Erzählen Sie uns, was Ihnen wichtig ist. Bitte keine Angaben zu Gesundheit oder Pflegegrad – das besprechen wir lieber persönlich.'],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich habe die Datenschutzhinweise gelesen. Meine Angaben werden verwendet, um meine Anfrage zu beantworten.'],
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Nachricht über tagespflege-moldovan.com', 'success' => 'Vielen Dank. Ihre Nachricht ist angekommen – wir melden uns bei Ihnen.', 'replyToField' => 'email'], 0);
|
||||
echo "Formular angelegt: kontakt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopfbereich: schmale Leiste, darunter der weiße Kopf, der beim Scrollen oben bleibt
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Die Wortmarke: Schmetterling, darüber „Tagespflege“, darunter der Name. */
|
||||
$wortmarke = static function (string $praefix, string $bildhoehe, string $bildbreite, string $gross, string $klein) use ($zeile, $column, $bild, $text, $medien, $seiten): array {
|
||||
return $zeile($praefix.'-zeile', [
|
||||
$column($praefix.'-bild', [
|
||||
$bild($praefix.'-logo', $medien['marke'], 'Tagespflege Jacqueline Moldovan', [
|
||||
'desktop' => ['maxWidth' => $bildbreite, 'imageHeight' => $bildhoehe, 'objectFit' => 'contain', 'margin' => $seiten('0px')],
|
||||
], '/'),
|
||||
], ['desktop' => ['flexShrink' => 0]]),
|
||||
$column($praefix.'-schrift', [
|
||||
$text($praefix.'-name', 'Tagespflege', ['desktop' => ['fontSize' => $gross, 'fontWeight' => '700', 'letterSpacing' => '-0.04em', 'lineHeight' => 1.3, 'color' => '@marke', 'margin' => ['bottom' => '0px']]]),
|
||||
$text($praefix.'-zusatz', 'Jacqueline Moldovan', ['desktop' => ['fontSize' => $klein, 'color' => '#62676b', 'lineHeight' => 1.3, 'margin' => ['top' => '3px', 'bottom' => '0px']]]),
|
||||
]),
|
||||
], '11px', ['flexShrink' => 0]);
|
||||
};
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf-leiste', [
|
||||
$row('kopf-leiste-zeile', [
|
||||
$column('kopf-leiste-ort', [
|
||||
$text('kopf-leiste-ort-text', 'Mitten in Baden-Baden. Mitten im Leben.', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'margin' => ['bottom' => '0px']]]),
|
||||
], [], 'hide-mobile'),
|
||||
$column('kopf-leiste-kontakt', [
|
||||
$text('kopf-leiste-telefon', '<a href="'.$telHref.'">Ihr direkter Kontakt: '.$telefon.'</a>', [
|
||||
'desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'textAlign' => 'right', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '12px', 'textAlign' => 'center'],
|
||||
], '& a:hover{text-decoration:underline}'),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], ['desktop' => ['alignItems' => 'center', 'margin' => ['top' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@leiste', 'color' => '#506054', 'padding' => ['top' => '8px', 'bottom' => '8px']],
|
||||
]),
|
||||
$section('kopf-haupt', [
|
||||
$row('kopf-haupt-zeile', [
|
||||
$column('kopf-marke', [$wortmarke('kopf-marke', '60px', '40px', '24px', '15px')], ['desktop' => ['flexShrink' => 0]]),
|
||||
$column('kopf-navigation', [
|
||||
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'mobile', 'burgerLabel' => 'both'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '15px', 'justifyContent' => 'flex-end', 'display' => 'flex',
|
||||
// Der Menü-Knopf im Zuschnitt der Vorlage: heller Rahmen, kaum gerundet.
|
||||
'burgerColor' => '@tinte', 'burgerBorderColor' => '#d8e0d1', 'burgerBorderRadius' => '3px', 'burgerFontSize' => '14px',
|
||||
]],
|
||||
'css' => "& .menu-list{gap:4px 29px;justify-content:flex-end}\n& .menu-list a{transition:color .2s}\n& .menu-list a:hover{color:#688f30;text-decoration:none}\n& .menu-list>li:last-child>a{border:1px solid #cfd9ce;border-radius:4px;padding:12px 19px}",
|
||||
],
|
||||
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'flex-end', 'alignItems' => 'center']]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'space-between', 'gap' => '25px', 'minHeight' => '100px', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['minHeight' => '86px'],
|
||||
'mobile' => ['minHeight' => '80px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '#fffffff7', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e7ece7', 'padding' => ['top' => '0px', 'bottom' => '0px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Fußbereich
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss', [
|
||||
$zeile('fuss-oben', [
|
||||
$column('fuss-marke', [$wortmarke('fuss-marke', '51px', '34px', '19px', '13px')], ['desktop' => ['flexShrink' => 0]]),
|
||||
$column('fuss-spruch', [
|
||||
$text('fuss-spruch-text', 'Mit Aufmerksamkeit. Mit Freude. Mit Herz.', [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontStyle' => 'italic', 'fontSize' => '17px', 'color' => '#737b78', 'textAlign' => 'center', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['maxWidth' => '210px'],
|
||||
'mobile' => ['fontSize' => '15px', 'textAlign' => 'left', 'maxWidth' => '100%'],
|
||||
]),
|
||||
], ['mobile' => ['order' => 3, 'flexBasis' => '100%']]),
|
||||
$column('fuss-nachoben', [
|
||||
$text('fuss-nachoben-text', '<a href="#start">Nach oben ↑</a>', [
|
||||
'desktop' => ['fontSize' => '13px', 'textAlign' => 'right', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['margin' => ['left' => 'auto']],
|
||||
], '& a:hover{text-decoration:underline}'),
|
||||
]),
|
||||
], '25px', ['justifyContent' => 'space-between', 'padding' => ['top' => '37px', 'bottom' => '37px']], ['flexWrap' => 'wrap', 'gap' => '22px', 'padding' => ['top' => '30px', 'bottom' => '30px']]),
|
||||
$zeile('fuss-unten', [
|
||||
$column('fuss-recht', [
|
||||
$text('fuss-copyright', '© 2026 Tagespflege Jacqueline Moldovan', ['desktop' => ['fontSize' => '12px', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '11px']]),
|
||||
]),
|
||||
$column('fuss-hinweis', [
|
||||
// Impressum und Datenschutz kommen aus den Einstellungen, nicht aus einem Menü.
|
||||
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
|
||||
'style' => ['desktop' => ['fontSize' => '12px', 'justifyContent' => 'flex-end', 'gap' => '4px'], 'mobile' => ['justifyContent' => 'flex-start']],
|
||||
'css' => "& a:hover{text-decoration:underline}"],
|
||||
]),
|
||||
], '20px', ['justifyContent' => 'space-between', 'alignItems' => 'flex-start', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e7ebe4', 'padding' => ['top' => '18px', 'bottom' => '18px'], 'color' => '#78817b'], ['flexDirection' => 'column', 'gap' => '7px']),
|
||||
], [
|
||||
'desktop' => ['borderWidth' => $nurUnten('7px'), 'borderStyle' => 'solid', 'borderColor' => '@limette', 'padding' => ['top' => '0px', 'bottom' => '0px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
/**
|
||||
* Eine Rechtsseite: Kleinzeile, Titel, Vorspann, dann Absätze mit Zwischenüberschriften.
|
||||
*
|
||||
* Stellen, die nur die Tagespflege selbst beantworten kann, stehen als „NOCH ZU PRÜFEN“ im Text,
|
||||
* statt erfunden zu werden – eine falsche Registerangabe ist schlimmer als eine fehlende.
|
||||
*/
|
||||
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $abschnitt, $row, $column, $kleinzeile, $heading, $text, $serif, $emGruen, $nurOben, $nurUnten): array {
|
||||
// Oben ein ruhiges Band in der Farbe des Angebotsbereichs: Kleinzeile, Titel, Vorspann.
|
||||
// Die Sektion bleibt voll breit; die Breite begrenzt erst der Inhalt darin. So steht der
|
||||
// Text auf derselben Kante wie Marke und Menü im Kopf, statt am Fensterrand zu kleben.
|
||||
$kopf = $section($praefix.'-kopf', [
|
||||
[...$kleinzeile($praefix.'-label', $label), 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
|
||||
[...$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => [...$serif, 'fontSize' => '59px', 'margin' => ['bottom' => '22px']],
|
||||
'tablet' => ['fontSize' => '46px'],
|
||||
'mobile' => ['fontSize' => '40px', 'margin' => ['bottom' => '16px']],
|
||||
], $emGruen), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
|
||||
[...$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '19px', 'lineHeight' => 1.7, 'color' => '@gedaempft', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '17px'],
|
||||
]), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'medium']]],
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@blass', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '84px', 'bottom' => '72px']],
|
||||
'tablet' => ['padding' => ['top' => '64px', 'bottom' => '56px']],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '40px']],
|
||||
]);
|
||||
|
||||
// Darunter jeder Punkt als eigene Zeile: Zwischentitel links, Text rechts, getrennt durch
|
||||
// eine feine Linie. Auf dem Telefon untereinander.
|
||||
$punkte = [];
|
||||
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
|
||||
$punkte[] = $row($praefix.'-punkt-'.$i, [
|
||||
$column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [
|
||||
$heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
|
||||
'desktop' => ['fontFamily' => 'body', 'fontSize' => '17px', 'fontWeight' => '700', 'letterSpacing' => '-0.01em', 'lineHeight' => 1.5, 'color' => '@tinte', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['flexBasis' => '280px', 'flexShrink' => 0],
|
||||
'tablet' => ['flexBasis' => '220px'],
|
||||
'mobile' => ['flexBasis' => 'auto'],
|
||||
]),
|
||||
$column($praefix.'-punkt-'.$i.'-text', [
|
||||
$text($praefix.'-p-'.$i, $inhalt, [
|
||||
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@gedaempft', 'margin' => ['bottom' => '0px']],
|
||||
], "& a{color:#2e7595;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#245e79}"),
|
||||
], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0']]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '48px', 'margin' => ['top' => '0px'],
|
||||
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '28px', 'bottom' => '28px']],
|
||||
'tablet' => ['gap' => '32px'],
|
||||
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '22px', 'bottom' => '22px']],
|
||||
]);
|
||||
}
|
||||
|
||||
$inhalt = $abschnitt($praefix, [
|
||||
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '960px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '56px', 'bottom' => '100px']], 'mobile' => ['padding' => ['top' => '28px', 'bottom' => '61px']]]);
|
||||
|
||||
return [$kopf, $inhalt];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$werte = ['Persönlich begleitet', 'Gemeinsam aktiv', 'Mit Freude durch den Tag'];
|
||||
|
||||
// Nummer, Symbol, Farbe des oberen Rands, Titel, Text – die drei Karten aus „Unser Angebot“.
|
||||
$angebote = [
|
||||
['01 / MITEINANDER', 'people', '#a9c563', '#f0f5e7', '#6b8f36', 'Gesellschaft genießen', 'Erzählen, zuhören, gemeinsam lachen. Hier können aus Begegnungen vertraute Beziehungen werden.'],
|
||||
['02 / LEBENSFREUDE', 'music', '#4285a2', '#edf5f8', '#347895', 'Mit Freude aktiv sein', 'Musik, Bewegung und kreative Momente bringen Abwechslung in den Tag. Jeder macht mit, wie es sich gut anfühlt.'],
|
||||
['03 / GEBORGENHEIT', 'home', '#bdce62', '#f0f5e7', '#6b8f36', 'Sich aufgehoben fühlen', 'Ein liebevoll gedeckter Tisch, Zeit zum Durchatmen und aufmerksame Begleitung geben dem Tag einen vertrauten Rahmen.'],
|
||||
];
|
||||
|
||||
$tagesablauf = [
|
||||
['01', 'ANKOMMEN', 'In Ruhe in den Tag starten', 'Ein freundliches Willkommen, ein gemeinsames Frühstück und Zeit für die ersten Gespräche.'],
|
||||
['02', 'GEMEINSAM ERLEBEN', 'Den Vormittag gestalten', 'Ein Spiel, etwas Bewegung oder gemeinsam kreativ werden – passend zu Lust und Möglichkeiten.'],
|
||||
['03', 'GENIESSEN & AUSRUHEN', 'Zeit für eine Pause', 'Zusammen Mittag essen und anschließend entspannen. Jeder darf seinen eigenen Rhythmus finden.'],
|
||||
['04', 'DEN TAG AUSKLINGEN LASSEN', 'Mit einem Lächeln nach Hause', 'Kaffee, eine gemütliche Runde und schöne Erinnerungen, die mit nach Hause gehen.'],
|
||||
];
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Tagespflege Moldovan',
|
||||
'description' => 'Tagespflege Jacqueline Moldovan in Baden-Baden. Ein Ort für Begegnung, Geborgenheit und schöne gemeinsame Tage.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: Text links, Foto rechts auf einer limettenen Form, Notizkarte darüber.
|
||||
$section('hero', [
|
||||
$row('hero-zeile', [
|
||||
$column('hero-text', [
|
||||
$kleinzeile('hero-label', 'Tagespflege in Baden-Baden'),
|
||||
$heading('hero-titel', 'Ein schöner Tag.<br>Ein gutes Gefühl.<br><em>Ein Stück Zuhause.</em>', 1, [
|
||||
'desktop' => [...$serif, 'fontSize' => '68px', 'margin' => ['bottom' => '25px']],
|
||||
'tablet' => ['fontSize' => '42px'],
|
||||
'mobile' => ['fontSize' => '43px', 'lineHeight' => 1.12, 'margin' => ['bottom' => '21px']],
|
||||
], $emGruen),
|
||||
$text('hero-intro', 'Gemeinsam lachen, Neues erleben und sich gut aufgehoben fühlen. Wir geben dem Tag Leben – und Angehörigen ein wenig Freiraum.', [
|
||||
'desktop' => ['maxWidth' => '465px', 'color' => '@gedaempft', 'fontSize' => '17px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '28px']],
|
||||
'mobile' => ['fontSize' => '16px', 'maxWidth' => '520px'],
|
||||
]),
|
||||
$zeile('hero-aktionen', [
|
||||
$column('hero-aktion-knopf', [
|
||||
$button('hero-knopf', 'Lernen wir uns kennen', '#kontakt', ['desktop' => ['borderRadius' => $ecken('4px'), 'margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['flexShrink' => 0]]),
|
||||
$column('hero-aktion-link', [
|
||||
$text('hero-textlink', '<a href="#bei-uns">Unsere Tagespflege entdecken ↓</a>', [
|
||||
'desktop' => ['fontSize' => '14px', 'padding' => ['top' => '15px', 'bottom' => '15px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#cad4c8', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
], ['desktop' => ['flexShrink' => 0]]),
|
||||
], '18px', ['alignItems' => 'flex-start', 'flexWrap' => 'wrap'], ['rowGap' => '8px', 'columnGap' => '20px']),
|
||||
$text('hero-notiz', "Tagsüber in guter Gesellschaft.\nAbends im vertrauten Zuhause.", [
|
||||
'desktop' => ['fontSize' => '14px', 'lineHeight' => 1.6, 'color' => '#737a7a', 'borderWidth' => $nurLinks('3px'), 'borderStyle' => 'solid', 'borderColor' => '@limette', 'padding' => ['left' => '15px'], 'margin' => ['top' => '29px', 'bottom' => '0px']],
|
||||
'mobile' => ['margin' => ['top' => '20px', 'bottom' => '0px']],
|
||||
]),
|
||||
]),
|
||||
$column('hero-visual', [
|
||||
// Die limettene Form hinter dem Foto – dieselbe Schnittkante wie in der Vorlage.
|
||||
$flaeche('hero-form', [
|
||||
'desktop' => ['position' => 'absolute', 'top' => '-19px', 'right' => '-17px', 'bottom' => '35px', 'left' => '35px', 'height' => 'auto', 'width' => 'auto', 'backgroundColor' => '@limette', 'zIndex' => 0],
|
||||
'mobile' => ['top' => '-14px', 'right' => '-9px', 'bottom' => '28px', 'left' => '30px'],
|
||||
], 'clip-path:polygon(18% 0,100% 0,81% 100%,0 100%)'),
|
||||
$bild('hero-bild', $medien['gemeinsam'], 'Illustrative Szene: Eine Seniorin und eine Begleiterin lachen gemeinsam an einem Tisch.', [
|
||||
'desktop' => ['position' => 'relative', 'zIndex' => 1, 'imageHeight' => '465px', 'objectFit' => 'cover', 'objectPosition' => 'center', 'imageRadius' => ['topLeft' => '110px', 'topRight' => '6px', 'bottomRight' => '6px', 'bottomLeft' => '6px'], 'margin' => $seiten('0px')],
|
||||
'tablet' => ['imageHeight' => '390px'],
|
||||
'mobile' => ['imageHeight' => '380px', 'imageRadius' => ['topLeft' => '85px', 'topRight' => '6px', 'bottomRight' => '6px', 'bottomLeft' => '6px']],
|
||||
], null, '& img{object-position:54% 45%}'),
|
||||
$column('hero-karte', [
|
||||
$text('hero-karte-label', 'WAS WIR UNS WÜNSCHEN', $klein),
|
||||
$text('hero-karte-text', "Dass Sie gerne\n<em>wiederkommen.</em>", [
|
||||
'desktop' => [...$serif, 'fontSize' => '30px', 'lineHeight' => 1.15, 'letterSpacing' => '-0.02em', 'margin' => ['top' => '11px', 'bottom' => '15px']],
|
||||
'mobile' => ['fontSize' => '27px'],
|
||||
], '& em{color:#73933d}'),
|
||||
$flaeche('hero-karte-strich', ['desktop' => ['width' => '36px', 'height' => '3px', 'backgroundColor' => '@limette']]),
|
||||
], [
|
||||
'desktop' => ['position' => 'absolute', 'left' => '-25px', 'bottom' => '-23px', 'zIndex' => 2, 'backgroundColor' => '@papier', 'minWidth' => '245px', 'padding' => ['top' => '22px', 'right' => '28px', 'bottom' => '17px', 'left' => '28px'], 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#edf0e9', 'shadowY' => '8px', 'shadowBlur' => '35px', 'shadowColor' => '#29465513'],
|
||||
'tablet' => ['left' => '-12px', 'minWidth' => '190px', 'padding' => ['top' => '18px', 'right' => '22px', 'bottom' => '14px', 'left' => '22px']],
|
||||
'mobile' => ['left' => '-12px', 'bottom' => '-25px', 'minWidth' => '190px', 'padding' => ['top' => '20px', 'right' => '25px', 'bottom' => '16px', 'left' => '25px']],
|
||||
]),
|
||||
$text('hero-caption', 'Gemeinschaft, die guttut.', [
|
||||
'desktop' => ['position' => 'absolute', 'right' => '0px', 'bottom' => '-31px', 'fontSize' => '12px', 'color' => '#737b79', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['position' => 'relative', 'margin' => ['left' => '10px', 'bottom' => '25px']],
|
||||
'tablet' => ['margin' => ['left' => '0px', 'bottom' => '25px']],
|
||||
'mobile' => ['margin' => ['top' => '0px', 'right' => '6px', 'bottom' => '22px', 'left' => '12px']],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '45px', 'alignItems' => 'center', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '25px'],
|
||||
'mobile' => ['gap' => '45px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['minHeight' => '650px', 'display' => 'flex', 'alignItems' => 'center', 'padding' => ['top' => '67px', 'bottom' => '75px']],
|
||||
'tablet' => ['minHeight' => 'auto', 'padding' => ['top' => '50px', 'bottom' => '75px']],
|
||||
'mobile' => ['minHeight' => 'auto', 'padding' => ['top' => '42px', 'bottom' => '57px']],
|
||||
], 'start'),
|
||||
|
||||
// 2 – Das Band mit den drei Versprechen.
|
||||
$section('werte', [
|
||||
$row('werte-zeile', array_map(
|
||||
static fn (string $wert, int $i): array => $column('wert-'.($i + 1), [
|
||||
$symbol('wert-'.($i + 1).'-haken', 'check', 16, ['desktop' => ['color' => '#789d3e', 'flexShrink' => 0]]),
|
||||
$text('wert-'.($i + 1).'-text', $wert, ['desktop' => ['fontSize' => '15px', 'letterSpacing' => '0.015em', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '14px']]),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'gap' => '13px']
|
||||
+ (0 === $i ? [] : ['borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '1px'], 'borderStyle' => 'solid', 'borderColor' => '#dbe2d6']),
|
||||
'mobile' => ['justifyContent' => 'flex-start', 'gap' => '16px', 'borderStyle' => 'none'],
|
||||
]),
|
||||
$werte, array_keys($werte),
|
||||
), ['desktop' => ['columns' => 'three'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['margin' => ['top' => '0px']],
|
||||
'mobile' => ['gap' => '12px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '22px', 'bottom' => '22px']],
|
||||
'mobile' => ['padding' => ['top' => '20px', 'bottom' => '20px']],
|
||||
]),
|
||||
|
||||
// 3 – Willkommen: Überschrift links, Text und Signatur rechts.
|
||||
$abschnitt('vorstellung', [
|
||||
$row('vorstellung-zeile', [
|
||||
$column('vorstellung-titel', [
|
||||
$kleinzeile('vorstellung-label', 'Willkommen bei uns'),
|
||||
$heading('vorstellung-h2', 'Hier zählt der Mensch.<br><em>Und das Miteinander.</em>', 2, $h2Stil, $emGruen),
|
||||
]),
|
||||
$column('vorstellung-text', [
|
||||
$text('vorstellung-lead', 'Manchmal braucht es nur ein vertrautes Gesicht, ein gutes Gespräch und das Gefühl, dazuzugehören.', [
|
||||
'desktop' => ['fontSize' => '20px', 'lineHeight' => 1.7, 'color' => '@tinte', 'margin' => ['bottom' => '20px']],
|
||||
'mobile' => ['fontSize' => '18px'],
|
||||
]),
|
||||
$text('vorstellung-p1', 'Unsere Tagespflege ist ein Ort für ältere Menschen, die ihren Tag in Gesellschaft verbringen möchten. Dabei ist uns wichtig, Raum für persönliche Gewohnheiten zu lassen: für Aktivität ebenso wie für eine ruhige Pause.', $fliess),
|
||||
$text('vorstellung-p2', 'Mit Aufmerksamkeit und einem offenen Ohr möchten wir den Alltag unserer Gäste bereichern und ihre Angehörigen entlasten.', $fliess),
|
||||
$zeile('vorstellung-signatur', [
|
||||
$column('signatur-bild', [
|
||||
$bild('signatur-portraet', $medien['portraet'], 'Jacqueline Moldovan', [
|
||||
'desktop' => ['maxWidth' => '104px', 'imageHeight' => '104px', 'objectFit' => 'cover', 'objectPosition' => 'top', 'imageRadius' => $ecken('50%'), 'imageBorderWidth' => $seiten('4px'), 'imageBorderStyle' => 'solid', 'imageBorderColor' => '@papier', 'margin' => $seiten('0px')],
|
||||
'tablet' => ['maxWidth' => '86px', 'imageHeight' => '86px'],
|
||||
'mobile' => ['maxWidth' => '86px', 'imageHeight' => '86px'],
|
||||
], null),
|
||||
], ['desktop' => ['flexShrink' => 0]]),
|
||||
$flaeche('signatur-strich', ['desktop' => ['width' => '28px', 'height' => '2px', 'backgroundColor' => '@gruen', 'flexShrink' => 0], 'tablet' => ['width' => '20px']]),
|
||||
$column('signatur-name', [
|
||||
$text('signatur-person', '<strong>Jacqueline Moldovan</strong>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '600', 'color' => '#2e6682', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('signatur-rolle', 'Geschäftsführerin', ['desktop' => ['fontSize' => '13px', 'color' => '@gedaempft', 'margin' => ['top' => '4px', 'bottom' => '0px']]]),
|
||||
]),
|
||||
], '18px', ['flexWrap' => 'wrap', 'margin' => ['top' => '29px']], ['gap' => '14px']),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '80px', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '50px'],
|
||||
'mobile' => ['gap' => '28px'],
|
||||
]),
|
||||
], [], 'bei-uns'),
|
||||
|
||||
// 4 – Unser Angebot: drei Karten auf blasser Fläche.
|
||||
$abschnitt('angebot-abschnitt', [
|
||||
$zeile('angebot-kopf', [
|
||||
$column('angebot-kopf-titel', [
|
||||
$kleinzeile('angebot-label', 'Raum für einen guten Tag'),
|
||||
$heading('angebot-h2', 'Was den Alltag<br><em>schöner macht.</em>', 2, $h2Stil, $emGruen),
|
||||
]),
|
||||
$column('angebot-kopf-text', [
|
||||
$text('angebot-intro', "Die kleinen Dinge machen den Unterschied.\nUnd Menschen, die sich Zeit nehmen.", [
|
||||
'desktop' => ['fontSize' => '15px', 'color' => '@gedaempft', 'margin' => ['bottom' => '5px']],
|
||||
'tablet' => ['maxWidth' => '260px'],
|
||||
'mobile' => ['margin' => ['top' => '23px', 'bottom' => '0px']],
|
||||
]),
|
||||
]),
|
||||
], '35px', ['alignItems' => 'flex-end', 'justifyContent' => 'space-between', 'margin' => ['top' => '0px', 'bottom' => '46px']], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '0px', 'margin' => ['top' => '0px', 'bottom' => '30px']]),
|
||||
$row('angebot-raster', array_map(
|
||||
static function (array $a, int $i) use ($column, $text, $symbol, $heading, $h3Stil, $seiten, $ecken): array {
|
||||
$n = $i + 1;
|
||||
|
||||
return $column('angebot-'.$n, [
|
||||
$text('angebot-'.$n.'-nummer', $a[0], ['desktop' => ['fontSize' => '11px', 'fontWeight' => '600', 'letterSpacing' => '0.14em', 'color' => '#788574', 'margin' => ['bottom' => '0px']]]),
|
||||
$symbol('angebot-'.$n.'-symbol', $a[1], 32, [
|
||||
'desktop' => ['width' => '55px', 'height' => '55px', 'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'borderRadius' => $ecken('50%'), 'backgroundColor' => $a[3], 'color' => $a[4], 'margin' => ['top' => '31px', 'bottom' => '23px']],
|
||||
'mobile' => ['width' => '48px', 'height' => '48px'],
|
||||
]),
|
||||
$heading('angebot-'.$n.'-titel', $a[5], 3, $h3Stil),
|
||||
$text('angebot-'.$n.'-text', $a[6], ['desktop' => ['fontSize' => '15px', 'color' => '@gedaempft', 'lineHeight' => 1.8, 'margin' => ['bottom' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'borderWidth' => ['top' => '3px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => $a[2], 'padding' => ['top' => '30px', 'right' => '29px', 'bottom' => '28px', 'left' => '29px']],
|
||||
'tablet' => ['padding' => ['top' => '23px', 'right' => '19px', 'bottom' => '23px', 'left' => '19px']],
|
||||
'mobile' => ['padding' => ['top' => '25px', 'right' => '27px', 'bottom' => '25px', 'left' => '27px']],
|
||||
]);
|
||||
},
|
||||
$angebote, array_keys($angebote),
|
||||
), ['desktop' => ['columns' => 'three'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '25px', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '15px'],
|
||||
'mobile' => ['gap' => '17px'],
|
||||
]),
|
||||
], ['desktop' => ['backgroundColor' => '@blass']], 'angebot'),
|
||||
|
||||
// 5 – Ein Tag bei uns: Einleitung links, die Zeitleiste rechts.
|
||||
$abschnitt('tagesablauf-abschnitt', [
|
||||
$row('tag-zeile', [
|
||||
$column('tag-intro', [
|
||||
$kleinzeile('tag-label', 'Ein Tag bei uns'),
|
||||
$heading('tag-h2', 'Ein guter Rhythmus.<br><em>Ganz ohne Eile.</em>', 2, $h2Stil, $emGruen),
|
||||
$text('tag-intro-text', 'So könnte ein gemeinsamer Tag aussehen. Mit vertrauten Ritualen, neuen Impulsen und Zeit für das, was Ihnen Freude macht.', [
|
||||
'desktop' => ['maxWidth' => '375px', 'color' => '@gedaempft', 'margin' => ['top' => '27px', 'bottom' => '0px']],
|
||||
'mobile' => ['maxWidth' => '100%'],
|
||||
]),
|
||||
$bild('tag-marke', $medien['marke'], '', [
|
||||
'desktop' => ['maxWidth' => '110px', 'opacity' => 0.65, 'margin' => ['top' => '36px']],
|
||||
]) + ['layout' => ['visibility' => 'hide-mobile']],
|
||||
]),
|
||||
$column('tag-liste', array_map(
|
||||
static function (array $s, int $i) use ($zeile, $column, $text, $heading, $flaeche, $klein, $h3Stil, $ecken, $seiten, $tagesablauf): array {
|
||||
$n = $i + 1;
|
||||
$letzte = $n === count($tagesablauf);
|
||||
$links = [
|
||||
$text('tag-'.$n.'-zahl', $s[0], ['desktop' => [
|
||||
'width' => '42px', 'height' => '42px', 'flexShrink' => 0, 'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center',
|
||||
'backgroundColor' => '#f2f6e9', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#dbe7cc', 'borderRadius' => $ecken('50%'),
|
||||
'fontSize' => '12px', 'color' => '#72943d', 'margin' => ['bottom' => '0px'],
|
||||
]]),
|
||||
];
|
||||
if (!$letzte) {
|
||||
$links[] = $flaeche('tag-'.$n.'-linie', ['desktop' => ['width' => '1px', 'height' => 'auto', 'flexGrow' => 1, 'backgroundColor' => '#dbe3d4']]);
|
||||
}
|
||||
|
||||
return $zeile('tag-schritt-'.$n, [
|
||||
$column('tag-'.$n.'-spur', $links, ['desktop' => ['width' => '43px', 'flexShrink' => 0, 'display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'center']]),
|
||||
$column('tag-'.$n.'-inhalt', [
|
||||
$text('tag-'.$n.'-label', $s[1], $klein),
|
||||
$heading('tag-'.$n.'-titel', $s[2], 3, ['desktop' => [...$h3Stil['desktop'], 'margin' => ['top' => '7px', 'bottom' => '9px']]]),
|
||||
$text('tag-'.$n.'-text', $s[3], ['desktop' => ['fontSize' => '15px', 'color' => '@gedaempft', 'lineHeight' => 1.75, 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
], '24px', ['alignItems' => 'stretch', 'padding' => ['bottom' => $letzte ? '0px' : '34px']], ['gap' => '19px']);
|
||||
},
|
||||
$tagesablauf, array_keys($tagesablauf),
|
||||
)),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '90px', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '50px'],
|
||||
'mobile' => ['gap' => '28px'],
|
||||
]),
|
||||
], [], 'tagesablauf'),
|
||||
|
||||
// 6 – Kontakt: dunkelblaue Fläche, rechts die weiße Formularkarte.
|
||||
$section('kontakt-abschnitt', [
|
||||
// Die schräge hellere Fläche am rechten Rand, mit dem limettenen Streifen davor.
|
||||
$flaeche('kontakt-form', [
|
||||
'desktop' => ['position' => 'absolute', 'top' => '0px', 'bottom' => '0px', 'right' => '-80px', 'width' => '180px', 'height' => 'auto', 'backgroundColor' => '#315d71', 'borderWidth' => $nurLinks('12px'), 'borderStyle' => 'solid', 'borderColor' => '#a1bb59', 'zIndex' => 0],
|
||||
'mobile' => ['right' => '-150px'],
|
||||
], 'transform:skewX(-16deg);pointer-events:none'),
|
||||
$row('kontakt-zeile', [
|
||||
$column('kontakt-text', [
|
||||
$kleinzeile('kontakt-label', 'Der erste Schritt ist ein Gespräch', '#c4d998', '@limette', '22px'),
|
||||
$heading('kontakt-h2', 'Ein gutes Gefühl<br>beginnt mit einem<br><em>persönlichen Hallo.</em>', 2, [
|
||||
'desktop' => [...$serif, 'fontSize' => '59px', 'color' => '@papier', 'margin' => ['bottom' => '24px']],
|
||||
'tablet' => ['fontSize' => '45px'],
|
||||
'mobile' => ['fontSize' => '43px'],
|
||||
], '& em{color:#c1d786;font-weight:400}'),
|
||||
$text('kontakt-intro', 'Sie möchten unsere Tagespflege kennenlernen oder haben Fragen? Erzählen Sie uns, was Ihnen wichtig ist. Wir freuen uns auf Sie.', [
|
||||
'desktop' => ['maxWidth' => '430px', 'color' => '#d5e0e4', 'fontSize' => '15px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '28px']],
|
||||
]),
|
||||
// Ein Telefonlink, kein Button-Baustein: Buttons verweisen auf Seiten, nicht auf tel:.
|
||||
$text('kontakt-anrufen', '<a href="'.$telHref.'">Rufen Sie uns an ↗</a>', ['desktop' => ['margin' => ['bottom' => '0px']]],
|
||||
"& a{display:inline-flex;align-items:center;gap:25px;background:#c3d886;color:#27434a;border-radius:4px;padding:15px 22px;font-size:15px;font-weight:600;transition:background .2s,transform .2s}\n& a:hover{background:#d1e3a5;transform:translateY(-2px)}"),
|
||||
['id' => 'kontakt-daten', 'type' => 'contact',
|
||||
'props' => ['phone' => $telefon, 'email' => $mail, 'address' => "Fremersbergstraße 75\n76530 Baden-Baden"],
|
||||
'style' => ['desktop' => ['fontSize' => '15px', 'color' => '#dce7eb', 'gap' => '10px', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#ffffff30', 'padding' => ['top' => '26px'], 'margin' => ['top' => '30px']]],
|
||||
// Die Vorlage führt die Angaben ohne Symbole; die Adresse behält ihren Umbruch.
|
||||
'css' => "& svg{display:none}\n& span{display:block}\n& a{overflow-wrap:anywhere}\n& a:hover{text-decoration:underline}\n& span:first-child a{font-size:22px;color:#ffffff;letter-spacing:-0.025em}\n& span:last-child{white-space:pre-line;line-height:1.65;margin-top:5px}",
|
||||
],
|
||||
], ['desktop' => ['alignSelf' => 'center']]),
|
||||
$column('kontakt-karte', [
|
||||
$text('kontakt-karte-label', 'WIR FREUEN UNS AUF IHRE NACHRICHT', ['desktop' => [...$klein['desktop'], 'margin' => ['bottom' => '18px']]]),
|
||||
$heading('kontakt-karte-titel', 'Was können wir<br>für Sie tun?', 3, [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontWeight' => '400', 'fontSize' => '30px', 'lineHeight' => 1.25, 'letterSpacing' => '-0.02em', 'margin' => ['bottom' => '25px']],
|
||||
]),
|
||||
['id' => 'kontakt-formular', 'type' => 'form', 'props' => ['form' => 'kontakt'],
|
||||
// Felder und Knopf im Zuschnitt der Vorlage; der Wortlaut des Knopfes kommt vom Baustein.
|
||||
'css' => "& .form-field label{font-size:14px;font-weight:600;color:#294655}
|
||||
& .form-field input{background:#fafcf8;border:1px solid #cfd9ce;border-radius:4px;padding:12px 13px;font-size:16px;color:#294655}
|
||||
& .form-field textarea{background:#fafcf8;border:1px solid #cfd9ce;border-radius:4px;padding:12px 13px;font-size:16px;color:#294655;min-height:120px}
|
||||
& .field-note{font-size:12px;color:#748078}
|
||||
& .cms-button{width:100%;justify-content:center;border-radius:4px;margin-top:4px}",
|
||||
],
|
||||
$text('kontakt-formular-hinweis', 'Ihre Nachricht geht direkt an die Tagespflege. Wir verwenden Ihre Angaben ausschließlich, um Ihre Anfrage zu beantworten – mehr dazu in den <a href="/datenschutz">Datenschutzhinweisen</a>.', [
|
||||
'desktop' => ['fontSize' => '13px', 'lineHeight' => 1.65, 'color' => '#64736b', 'margin' => ['top' => '19px', 'bottom' => '0px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['alignSelf' => 'center', 'backgroundColor' => '@papier', 'color' => '@tinte', 'borderRadius' => $ecken('3px'), 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '#bdd26f', 'padding' => ['top' => '32px', 'right' => '35px', 'bottom' => '32px', 'left' => '35px']],
|
||||
'tablet' => ['padding' => ['top' => '28px', 'right' => '25px', 'bottom' => '28px', 'left' => '25px']],
|
||||
'mobile' => ['padding' => ['top' => '28px', 'right' => '25px', 'bottom' => '28px', 'left' => '25px']],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '100px', 'position' => 'relative', 'zIndex' => 1, 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '35px'],
|
||||
'mobile' => ['gap' => '35px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@dunkelblau', 'color' => '@papier', 'position' => 'relative', 'overflow' => 'hidden', 'padding' => ['top' => '83px', 'bottom' => '83px']],
|
||||
'mobile' => ['padding' => ['top' => '60px', 'bottom' => '60px']],
|
||||
], 'kontakt'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten anlegen und veröffentlichen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$erstellt = [];
|
||||
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db, &$erstellt): string {
|
||||
$id = $pages->createPage($slug, null, 'page', $dokument);
|
||||
if ('' !== $seoTitel) {
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
|
||||
}
|
||||
if ('' !== $teilbild) {
|
||||
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
|
||||
}
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
$erstellt[$slug] = $id;
|
||||
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
// Der Hero spielt beim Laden, alles Weitere beim Hereinscrollen. Gestaffelt, damit eine Zeile
|
||||
// nacheinander erscheint statt auf einen Schlag.
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-label' => ['fade'],
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-intro' => ['rise', 'medium'],
|
||||
'hero-aktionen' => ['rise', 'long'],
|
||||
'hero-notiz' => ['fade', 'long'],
|
||||
'hero-visual' => ['right', 'short'],
|
||||
'werte-zeile' => ['fade'],
|
||||
'vorstellung-titel' => ['rise'],
|
||||
'vorstellung-text' => ['rise', 'short'],
|
||||
'angebot-kopf' => ['rise'],
|
||||
'angebot-1' => ['rise'],
|
||||
'angebot-2' => ['rise', 'short'],
|
||||
'angebot-3' => ['rise', 'medium'],
|
||||
'tag-intro' => ['rise'],
|
||||
'tag-schritt-1' => ['rise'],
|
||||
'tag-schritt-2' => ['rise', 'short'],
|
||||
'tag-schritt-3' => ['rise', 'medium'],
|
||||
'tag-schritt-4' => ['rise', 'long'],
|
||||
'kontakt-text' => ['rise'],
|
||||
'kontakt-karte' => ['rise', 'short'],
|
||||
]);
|
||||
|
||||
$anlegen('home', $startseite, 'Tagespflege Moldovan · Gemeinsam den Tag genießen', $medien['gemeinsam']['id']);
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben der Tagespflege Jacqueline Moldovan in Baden-Baden.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
|
||||
['Anbieterin', "Tagespflege Jacqueline Moldovan<br>Fremersbergstraße 75<br>76530 Baden-Baden<br><br>Vertreten durch Jacqueline Moldovan."],
|
||||
['Kontakt', "Telefon: <a href=\"$telHref\">$telefon</a><br>E-Mail: <a href=\"mailto:$mail\">$mail</a>"],
|
||||
['Rechtsform und Register', 'NOCH ZU PRÜFEN: Rechtsform, Registergericht und Registernummer ergänzen, sofern eine Eintragung besteht.'],
|
||||
['Umsatzsteuer', 'NOCH ZU PRÜFEN: Umsatzsteuer-Identifikationsnummer eintragen oder auf die Steuerbefreiung für Pflegeleistungen nach § 4 Nr. 16 UStG hinweisen.'],
|
||||
['Aufsichtsbehörde', 'NOCH ZU PRÜFEN: zuständige Aufsicht für die Tagespflege eintragen, in Baden-Baden üblicherweise die Heimaufsicht der Stadt. Ebenfalls zu ergänzen: Berufsbezeichnung und zuständige Kammer, sofern einschlägig, sowie ein bestehender Versorgungsvertrag nach § 72 SGB XI.'],
|
||||
['Verantwortlich für den Inhalt', "Jacqueline Moldovan, Anschrift wie oben."],
|
||||
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
|
||||
['Bildnachweis', 'Porträt: Jacqueline Moldovan. Einstiegsbild: illustrative KI-Aufnahme, keine Abbildung tatsächlicher Gäste. Markenzeichen: eigene Gestaltung.'],
|
||||
['Haftung für Links', 'Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Für diese Inhalte ist stets die jeweilige Anbieterin oder der jeweilige Anbieter verantwortlich. Bei Bekanntwerden von Rechtsverletzungen entfernen wir solche Links umgehend.'],
|
||||
]),
|
||||
],
|
||||
], 'Impressum | Tagespflege Jacqueline Moldovan Baden-Baden');
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Wie die Tagespflege Jacqueline Moldovan Daten beim Besuch der Website und bei einer Kontaktaufnahme verarbeitet.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei Ihrer Kontaktaufnahme verarbeitet werden – und welche Rechte Sie haben.', [
|
||||
['1. Verantwortliche', "Tagespflege Jacqueline Moldovan, Fremersbergstraße 75, 76530 Baden-Baden. Telefon <a href=\"$telHref\">$telefon</a>, E-Mail <a href=\"mailto:$mail\">$mail</a>.<br><br>NOCH ZU PRÜFEN: Ob eine Datenschutzbeauftragte oder ein Datenschutzbeauftragter zu benennen ist, hängt von der Zahl der ständig mit der Verarbeitung beschäftigten Personen ab; gegebenenfalls hier die Kontaktdaten ergänzen."],
|
||||
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO.<br><br>NOCH ZU PRÜFEN: Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen, Verarbeitungsländer und die konkrete Speicherdauer der Serverprotokolle eintragen.'],
|
||||
['3. Cookies, Schriften, externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
|
||||
['4. Ihre Nachricht über das Kontaktformular', 'Wenn Sie uns schreiben, verarbeiten wir die von Ihnen angegebenen Kontakt- und Inhaltsdaten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn es um die Anbahnung einer Betreuung geht, sonst Art. 6 Abs. 1 lit. f DSGVO.<br><br>Bitte schildern Sie im Formular keine Gesundheitsangaben, keinen Pflegegrad und keine Diagnosen. Solche Angaben sind besonders geschützt (Art. 9 DSGVO); wir besprechen sie lieber persönlich oder am Telefon.'],
|
||||
['5. Wie lange wir speichern', 'Anfragedaten löschen wir, sobald sie für die Beantwortung nicht mehr nötig sind und keine gesetzliche Aufbewahrungspflicht entgegensteht. Entsteht aus Ihrer Anfrage ein Betreuungsverhältnis, gelten für die dann anfallenden Unterlagen die gesetzlichen Fristen.'],
|
||||
['6. Empfänger', 'Ihre Angaben erreichen die Tagespflege per E-Mail. Darüber hinaus geben wir sie nicht weiter, außer wir sind dazu gesetzlich verpflichtet.<br><br>NOCH ZU PRÜFEN: E-Mail-Anbieter und Hostingdienst als Auftragsverarbeiter benennen.'],
|
||||
['7. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen. Eine erteilte Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen.'],
|
||||
['8. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
|
||||
['', "Fragen zum Datenschutz beantworten wir gern unter <a href=\"mailto:$mail\">$mail</a>."],
|
||||
]),
|
||||
],
|
||||
], 'Datenschutz | Tagespflege Jacqueline Moldovan');
|
||||
|
||||
$fehlerId = $anlegen('404', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
|
||||
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
|
||||
'blocks' => [
|
||||
$abschnitt('fehler', [
|
||||
[...$kleinzeile('fehler-label', 'Fehler 404'), 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
|
||||
$heading('fehler-titel', 'Diese Seite<br><em>haben wir nicht.</em>', 1, [
|
||||
'desktop' => [...$serif, 'fontSize' => '59px', 'margin' => ['bottom' => '25px']],
|
||||
'mobile' => ['fontSize' => '40px'],
|
||||
], $emGruen),
|
||||
$text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [
|
||||
'desktop' => ['maxWidth' => '465px', 'color' => '@gedaempft', 'fontSize' => '17px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '28px']],
|
||||
]),
|
||||
$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['borderRadius' => $ecken('4px'), 'margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['padding' => ['top' => '120px', 'bottom' => '140px']]]),
|
||||
],
|
||||
]);
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Menü, Einstellungen, Konto
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$sprung = static fn (string $label, string $anchor): array => ['label' => $label, 'type' => 'page', 'pageId' => $erstellt['home'], 'anchor' => $anchor, 'children' => []];
|
||||
|
||||
$menus->save('header', 'Hauptnavigation', [
|
||||
$sprung('Bei uns', 'bei-uns'),
|
||||
$sprung('Unser Angebot', 'angebot'),
|
||||
$sprung('Ein Tag bei uns', 'tagesablauf'),
|
||||
$sprung('Kontakt aufnehmen ↗', 'kontakt'),
|
||||
], 0);
|
||||
echo "Menü angelegt\n";
|
||||
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => 'Tagespflege Jacqueline Moldovan',
|
||||
'description' => 'Tagespflege in Baden-Baden: ein Ort für ältere Menschen, die ihren Tag in Gesellschaft verbringen möchten. Persönliche Begleitung, gemeinsame Aktivitäten und Zeit für Ruhe – abends wieder im vertrauten Zuhause.',
|
||||
'phone' => $telefon, 'email' => $mail,
|
||||
'street' => 'Fremersbergstraße 75', 'postalCode' => '76530', 'city' => 'Baden-Baden', 'country' => 'DE',
|
||||
'localBusiness' => true, 'llmsTxt' => true,
|
||||
'stickyHeader' => true, 'headerOverlay' => false,
|
||||
'motion' => 'always',
|
||||
'faviconMediaId' => $medien['marke']['id'],
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
'consentBanner' => false, 'scrollTop' => false, 'postMetaHeader' => true,
|
||||
]);
|
||||
echo "Einstellungen gespeichert\n";
|
||||
|
||||
$passwort = bin2hex(random_bytes(12));
|
||||
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
|
||||
|
||||
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
|
||||
@@ -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)'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -172,8 +172,39 @@ final class Consent
|
||||
'decided' => null !== $decision,
|
||||
'decidedAt' => $decision['at'] ?? null,
|
||||
'text' => $settings['consentText'],
|
||||
'title' => $settings['consentTitle'],
|
||||
'layout' => $settings['consentLayout'],
|
||||
'privacyPath' => $settings['privacyPath'],
|
||||
'imprintPath' => $settings['imprintPath'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Gestaltung des Hinweises als CSS-Variablen für den Kopf der Seite.
|
||||
*
|
||||
* Das Stylesheet kennt die Standardwerte; hier stehen nur die Abweichungen, die jemand in den
|
||||
* Einstellungen gewählt hat. Die Form bestimmt zwei Radien auf einmal - Fläche und Knöpfe
|
||||
* gehören zusammen, und zwei Regler dafür wären zwei Regler, die man gegeneinander verstellt.
|
||||
*/
|
||||
public function styles(array $theme): string
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
if (!$settings['consentBanner']) {
|
||||
return '';
|
||||
}
|
||||
[$box, $button] = match ($settings['consentShape']) {
|
||||
'rund' => ['28px', '999px'],
|
||||
'eckig' => ['0', '0'],
|
||||
default => ['18px', '10px'],
|
||||
};
|
||||
$values = ['--kant-consent-radius:'.$box, '--kant-consent-button-radius:'.$button];
|
||||
foreach (['consentColor' => '--kant-consent-bg', 'consentInk' => '--kant-consent-ink', 'consentAccent' => '--kant-consent-accent', 'consentAccentInk' => '--kant-consent-accent-ink'] as $field => $variable) {
|
||||
$value = Theme::cssColor((string) $settings[$field], $theme);
|
||||
if (null !== $value) {
|
||||
$values[] = $variable.':'.$value;
|
||||
}
|
||||
}
|
||||
|
||||
return ':root{'.implode(';', $values).'}';
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ final class DocumentValidator
|
||||
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
||||
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
||||
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
||||
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
|
||||
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
|
||||
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
||||
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
|
||||
public const V7_TYPES = ['tabs', 'tabitem'];
|
||||
@@ -33,6 +33,8 @@ final class DocumentValidator
|
||||
public const V19_TYPES = ['legal'];
|
||||
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
|
||||
public const V15_TYPES = ['icon', 'progress', 'slider'];
|
||||
/** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
|
||||
public const V23_TYPES = ['social'];
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
@@ -42,7 +44,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, 23], 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 +54,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,12 +77,17 @@ 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
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -96,6 +113,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 = [
|
||||
@@ -125,7 +156,7 @@ final class DocumentValidator
|
||||
$this->text($props['text'] ?? null, $limit);
|
||||
}
|
||||
if (!$plugin && array_key_exists('newTab', $props)) {
|
||||
$this->require($schemaVersion >= 20 && in_array($type, ['button', 'image', 'iconcard', 'cta', 'icon'], true), 'Neue Link-Tabs brauchen Schema v20 und einen verlinkbaren Block.');
|
||||
$this->require($schemaVersion >= 20 && in_array($type, ['button', 'image', 'iconcard', 'cta', 'icon', 'social'], true), 'Neue Link-Tabs brauchen Schema v20 und einen verlinkbaren Block.');
|
||||
$this->require(is_bool($props['newTab']), 'In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
foreach (($block['style'] ?? []) as $values) {
|
||||
@@ -220,6 +251,11 @@ final class DocumentValidator
|
||||
if ('spacer' === $type) {
|
||||
$this->require(in_array($props['size'] ?? null, self::SPACER_SIZES, true), 'Unknown spacer size.');
|
||||
}
|
||||
if ('accordion' === $type && array_key_exists('faq', $props)) {
|
||||
// Ein Akkordeon ist nicht automatisch eine FAQ; erst die Kennzeichnung macht aus den
|
||||
// Einträgen Frage-Antwort-Paare in den strukturierten Daten.
|
||||
$this->require($schemaVersion >= 23 && is_bool($props['faq']), 'Die FAQ-Kennzeichnung ist an oder aus und braucht Schema v23.');
|
||||
}
|
||||
if ('accordionitem' === $type) {
|
||||
$this->text($props['title'] ?? null, 160);
|
||||
$this->require(is_bool($props['open'] ?? false), 'The open state is a boolean.');
|
||||
@@ -231,6 +267,14 @@ 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 (array_key_exists('burgerLabel', $props)) {
|
||||
// Wie der Knopf beschriftet ist. „Nur Symbol“ behält den Text für Vorlesesoftware:
|
||||
// ein Knopf ohne Namen ist für sie ein Knopf ohne Zweck.
|
||||
$this->require(in_array($props['burgerLabel'], ['both', 'text', 'icon'], true), 'Für den Menü-Knopf sind „both", „text" und „icon" vorgesehen.');
|
||||
}
|
||||
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.');
|
||||
@@ -255,6 +299,19 @@ final class DocumentValidator
|
||||
$this->url($props['href']);
|
||||
}
|
||||
}
|
||||
if ('social' === $type) {
|
||||
// Nur die Darstellung ist Inhalt des Blocks. Welche Profile es gibt, steht in den
|
||||
// Einstellungen - ein Dokument kann kein Profil behaupten, das die Website nicht hat.
|
||||
$this->require(in_array($props['size'] ?? null, self::ICON_SIZES, true), sprintf('Die Symbolgröße ist eine aus %s.', implode(', ', self::ICON_SIZES)));
|
||||
$this->require(isset(SocialProfiles::SHAPES[$props['shape'] ?? '']), 'Unbekannte Form der Symbolfläche.');
|
||||
$this->require(isset(SocialProfiles::LAYOUTS[$props['layout'] ?? '']), 'Unbekannte Anordnung der Symbole.');
|
||||
$this->require(is_bool($props['labels'] ?? null), 'Die Beschriftung der Symbole ist an oder aus.');
|
||||
$networks = $props['networks'] ?? [];
|
||||
$this->require(is_array($networks) && array_is_list($networks), 'Die Auswahl der Netzwerke ist eine Liste.');
|
||||
foreach ($networks as $network) {
|
||||
$this->require(is_string($network) && isset(SocialProfiles::NETWORKS[$network]), 'Unbekanntes Netzwerk in der Auswahl.');
|
||||
}
|
||||
}
|
||||
if ('progress' === $type) {
|
||||
$this->text($props['label'] ?? null, 120);
|
||||
$this->require(is_int($props['value'] ?? null) && $props['value'] >= 0 && $props['value'] <= 100, 'Der Wert liegt zwischen 0 und 100.');
|
||||
|
||||
@@ -34,6 +34,12 @@ final class FontLibrary
|
||||
{
|
||||
}
|
||||
|
||||
/** Gibt es ein Stylesheet mit lokalen Schriften - auch wenn nicht jede Schrift des Themes dabei ist? */
|
||||
public function hasStylesheet(): bool
|
||||
{
|
||||
return is_file($this->directory.'/fonts.css');
|
||||
}
|
||||
|
||||
/** Liegen für dieses Theme fertige Dateien bereit? */
|
||||
public function hasLocal(array $theme): bool
|
||||
{
|
||||
@@ -108,9 +114,11 @@ final class FontLibrary
|
||||
/**
|
||||
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
|
||||
*
|
||||
* Nur der lateinische Grundbereich und nur die Schnitte, die im Fließtext und in
|
||||
* Überschriften stehen - ein Vorabholen, das alles holt, verzögert genau das, was es
|
||||
* beschleunigen soll.
|
||||
* Jeder Schnitt, den das Theme nennt, im lateinischen Grundbereich. Die früheren „ersten
|
||||
* zwei je Familie“ ließen genau den Schnitt aus, der die Überschriften trägt: Lato 900 lud
|
||||
* erst nach dem Stylesheet, und mit `font-display:swap` sprang die Überschrift sichtbar von
|
||||
* der Ersatzschrift auf die richtige. Die erweiterten Bereiche bleiben draußen - sie werden
|
||||
* nur für Zeichen gebraucht, die auf den meisten Seiten nicht vorkommen.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -118,7 +126,7 @@ final class FontLibrary
|
||||
{
|
||||
$preload = [];
|
||||
foreach ($this->wanted($theme) as $family => $weights) {
|
||||
foreach (array_slice($weights, 0, 2) as $weight) {
|
||||
foreach ($weights as $weight) {
|
||||
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
|
||||
if (is_file($this->directory.'/'.$name)) {
|
||||
$preload[] = '/assets/fonts/'.$name;
|
||||
|
||||
@@ -21,6 +21,13 @@ final class FormRepository
|
||||
'consent' => 'Einwilligung',
|
||||
];
|
||||
public const MAX_FIELDS = 25;
|
||||
/** Die gestaltbaren Farben eines Formulars, mit ihrer Bezeichnung in der Verwaltung. */
|
||||
public const COLORS = [
|
||||
'fieldBorderColor' => 'den Rahmen der Felder',
|
||||
'fieldBackgroundColor' => 'den Hintergrund der Felder',
|
||||
'buttonColor' => 'den Button',
|
||||
'buttonInk' => 'die Buttonschrift',
|
||||
];
|
||||
/** Submissions per sender per hour. Deliberately low: a contact form is not a chat. */
|
||||
public const RATE_LIMIT = 5;
|
||||
/** A human needs longer than this to fill in a form; anything faster is a script. */
|
||||
@@ -165,12 +172,66 @@ final class FormRepository
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
$clean = [
|
||||
'recipient' => $recipient,
|
||||
'subject' => mb_substr(trim((string) ($settings['subject'] ?? '')), 0, 160) ?: 'Neue Formularnachricht',
|
||||
'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.',
|
||||
'replyToField' => $replyTo,
|
||||
'buttonLabel' => mb_substr(trim((string) ($settings['buttonLabel'] ?? '')), 0, 40) ?: 'Absenden',
|
||||
];
|
||||
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
|
||||
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
|
||||
foreach (self::COLORS as $field => $label) {
|
||||
$value = trim((string) ($settings[$field] ?? ''));
|
||||
if ('' !== $value && 1 !== preg_match(StyleSchema::COLOR, $value)) {
|
||||
throw new InvalidForm(sprintf('„%s“ ist kein Farbwert für %s.', $value, $label));
|
||||
}
|
||||
$clean[$field] = $value;
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Gestaltung eines Formulars als Regel fuer die Seite, die es zeigt.
|
||||
*
|
||||
* Das Formular bekommt seine Farben als Variablen: Rahmen und Grund der Felder direkt, die
|
||||
* Buttonfarben ueber dieselben Token, die auch der Standardbutton der Website benutzt - so
|
||||
* behaelt der Absendeknopf Form und Darstellung aus dem Design und wechselt nur die Farbe.
|
||||
* Nichts gesetzt, nichts geschrieben: dann gilt weiter die Website.
|
||||
*/
|
||||
public function css(array $form, array $theme): string
|
||||
{
|
||||
$values = [];
|
||||
$button = false;
|
||||
foreach (['fieldBorderColor' => '--form-border', 'fieldBackgroundColor' => '--form-bg', 'buttonColor' => '--kb-fill', 'buttonInk' => '--kb-ink'] as $field => $variable) {
|
||||
$value = Theme::cssColor((string) ($form['settings'][$field] ?? ''), $theme);
|
||||
if (null === $value) {
|
||||
continue;
|
||||
}
|
||||
$values[] = $variable.':'.$value;
|
||||
if ('buttonColor' === $field) {
|
||||
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,#000)';
|
||||
$button = true;
|
||||
}
|
||||
if ('buttonInk' === $field) {
|
||||
$values[] = '--kb-hover-ink:'.$value;
|
||||
$button = true;
|
||||
}
|
||||
}
|
||||
if ($button) {
|
||||
// Die Darstellung (gefuellt, Rahmen, dezent) leitet Flaeche und Schrift aus Fuellung
|
||||
// und Tinte ab - am Wurzelelement, mit den Werten von dort. Hier noch einmal, damit
|
||||
// die Ableitung die Farben dieses Formulars nimmt.
|
||||
foreach (ButtonDesign::appearance($theme['buttons']['appearance'] ?? 'solid') as $key => $value) {
|
||||
$values[] = '--kb-'.$key.':'.$value;
|
||||
}
|
||||
}
|
||||
if ([] === $values) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf('.block-form[data-form="%s"]{%s}', $form['slug'], implode(';', $values));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,26 @@ final class SiteSettings
|
||||
'city' => '',
|
||||
'country' => 'DE',
|
||||
'sameAs' => [],
|
||||
// Die sozialen Profile, je Netzwerk eine Adresse (siehe SocialProfiles). Der Baustein
|
||||
// `social` zeigt sie an; die strukturierten Daten führen sie als sameAs mit.
|
||||
'social' => [],
|
||||
// Öffnungszeiten je Wochentag (opens/closes als HH:MM) und das Einsatzgebiet als Liste
|
||||
// von Ortsnamen. Beides landet in LocalBusiness, weil eine Suchmaschine sonst rät.
|
||||
'openingHours' => [],
|
||||
'areaServed' => [],
|
||||
'localBusiness' => false,
|
||||
'llmsTxt' => false,
|
||||
// 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,
|
||||
@@ -60,14 +75,86 @@ final class SiteSettings
|
||||
'consentBanner' => false,
|
||||
'consentGroups' => ['external'],
|
||||
'consentText' => '',
|
||||
// Sein Aussehen: Anordnung, Form, Farben, Überschrift. Leer heißt Theme-Standard.
|
||||
'consentLayout' => 'karte',
|
||||
'consentShape' => 'weich',
|
||||
'consentTitle' => '',
|
||||
'consentColor' => '',
|
||||
'consentInk' => '',
|
||||
'consentAccent' => '',
|
||||
'consentAccentInk' => '',
|
||||
'privacyPath' => '',
|
||||
'imprintPath' => '',
|
||||
];
|
||||
|
||||
/** Die Wochentage, wie schema.org sie nennt, mit deutscher Beschriftung für das Formular. */
|
||||
public const DAYS = ['Monday' => 'Montag', 'Tuesday' => 'Dienstag', 'Wednesday' => 'Mittwoch', 'Thursday' => 'Donnerstag', 'Friday' => 'Freitag', 'Saturday' => 'Samstag', 'Sunday' => 'Sonntag'];
|
||||
|
||||
public function __construct(private Connection $db, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Öffnungszeiten aus dem Formular: je Tag ein Paar aus Öffnen und Schließen; ein Tag ohne
|
||||
* beide Angaben gilt als geschlossen und fehlt in der Liste.
|
||||
*
|
||||
* @return list<array{day: string, opens: string, closes: string}>
|
||||
*/
|
||||
private function openingHours(mixed $input): array
|
||||
{
|
||||
$hours = [];
|
||||
if (is_array($input) && array_is_list($input)) {
|
||||
// Die gespeicherte Form: eine Liste von {day, opens, closes} wird zur Formularform.
|
||||
$map = [];
|
||||
foreach ($input as $row) {
|
||||
if (is_array($row) && isset($row['day'])) {
|
||||
$map[(string) $row['day']] = ['opens' => $row['opens'] ?? '', 'closes' => $row['closes'] ?? ''];
|
||||
}
|
||||
}
|
||||
$input = $map;
|
||||
}
|
||||
foreach (self::DAYS as $day => $label) {
|
||||
$row = is_array($input) && is_array($input[$day] ?? null) ? $input[$day] : [];
|
||||
$opens = trim((string) ($row['opens'] ?? ''));
|
||||
$closes = trim((string) ($row['closes'] ?? ''));
|
||||
if ('' === $opens && '' === $closes) {
|
||||
continue;
|
||||
}
|
||||
foreach ([$opens, $closes] as $time) {
|
||||
if (1 !== preg_match('/^([01]\d|2[0-3]):[0-5]\d$/D', $time)) {
|
||||
throw new InvalidPage(sprintf('Die Öffnungszeit für %s braucht Öffnen und Schließen als Uhrzeit wie 07:30.', $label));
|
||||
}
|
||||
}
|
||||
if ($opens >= $closes) {
|
||||
throw new InvalidPage(sprintf('%s: die Schließzeit muss nach der Öffnungszeit liegen.', $label));
|
||||
}
|
||||
$hours[] = ['day' => $day, 'opens' => $opens, 'closes' => $closes];
|
||||
}
|
||||
|
||||
return $hours;
|
||||
}
|
||||
|
||||
/** Das Einsatzgebiet: ein Ortsname je Zeile, höchstens vierzig. */
|
||||
private function areaServed(string $input): array
|
||||
{
|
||||
$places = [];
|
||||
foreach (preg_split('/[\r\n,]+/', $input) ?: [] as $place) {
|
||||
$place = trim($place);
|
||||
if ('' === $place) {
|
||||
continue;
|
||||
}
|
||||
if (mb_strlen($place) > 80 || 1 === preg_match('/[<>"]/', $place)) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist kein Ortsname.', mb_substr($place, 0, 40)));
|
||||
}
|
||||
$places[] = $place;
|
||||
}
|
||||
if (count($places) > 40) {
|
||||
throw new InvalidPage('Mehr als vierzig Orte im Einsatzgebiet sind nicht vorgesehen.');
|
||||
}
|
||||
|
||||
return array_values(array_unique($places));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembered for the length of one request.
|
||||
*
|
||||
@@ -125,12 +212,22 @@ final class SiteSettings
|
||||
$values['sameAs'][] = $profile;
|
||||
}
|
||||
$values['sameAs'] = array_slice(array_unique($values['sameAs']), 0, 20);
|
||||
$values['social'] = SocialProfiles::clean($input['social'] ?? []);
|
||||
// Zwei Formen kommen hier an: die des Formulars (hours[Monday][opens], Orte als Text) und
|
||||
// die gespeicherte (openingHours als Liste, areaServed als Liste), wenn ein anderer Bildschirm
|
||||
// die geladenen Werte unverändert zurückgibt. Beide müssen dasselbe ergeben, sonst löscht
|
||||
// das Anlegen der Rechtsseiten die Öffnungszeiten.
|
||||
$values['openingHours'] = $this->openingHours($input['hours'] ?? $input['openingHours'] ?? []);
|
||||
$areaInput = $input['areaServed'] ?? '';
|
||||
$values['areaServed'] = $this->areaServed(is_array($areaInput) ? implode('
|
||||
', array_map('strval', $areaInput)) : (string) $areaInput);
|
||||
$values['localBusiness'] = (bool) ($input['localBusiness'] ?? false);
|
||||
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
|
||||
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
|
||||
}
|
||||
$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 +270,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
|
||||
@@ -213,6 +311,16 @@ final class SiteSettings
|
||||
array_map(static fn (mixed $group): string => (string) $group, (array) ($input['consentGroups'] ?? [])),
|
||||
));
|
||||
$values['consentText'] = mb_substr(trim((string) ($input['consentText'] ?? '')), 0, 600);
|
||||
$values['consentLayout'] = in_array($input['consentLayout'] ?? '', ['karte', 'leiste', 'ecke'], true) ? $input['consentLayout'] : 'karte';
|
||||
$values['consentShape'] = in_array($input['consentShape'] ?? '', ['weich', 'rund', 'eckig'], true) ? $input['consentShape'] : 'weich';
|
||||
$values['consentTitle'] = mb_substr(trim((string) ($input['consentTitle'] ?? '')), 0, 80);
|
||||
foreach (['consentColor', 'consentInk', 'consentAccent', 'consentAccentInk'] as $feld) {
|
||||
$farbe = trim((string) ($input[$feld] ?? ''));
|
||||
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
|
||||
throw new InvalidPage('Die Farben des Einwilligungshinweises werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
|
||||
}
|
||||
$values[$feld] = $farbe;
|
||||
}
|
||||
foreach (['privacyPath', 'imprintPath'] as $key) {
|
||||
$path = trim(trim((string) ($input[$key] ?? '')), '/');
|
||||
if ('' !== $path && 1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $path)) {
|
||||
@@ -343,8 +451,25 @@ final class SiteSettings
|
||||
'addressCountry' => $settings['country'],
|
||||
], static fn (string $value): bool => '' !== $value);
|
||||
}
|
||||
if ([] !== $settings['sameAs']) {
|
||||
$data['sameAs'] = $settings['sameAs'];
|
||||
// Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch
|
||||
// das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste.
|
||||
$sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])]));
|
||||
if ([] !== $sameAs) {
|
||||
$data['sameAs'] = $sameAs;
|
||||
}
|
||||
// Tage mit denselben Zeiten werden zu einer Angabe: Mo-Fr 07:30-17:00 ist eine Zeile,
|
||||
// nicht fünf. Das ist die Form, die Google für openingHoursSpecification dokumentiert.
|
||||
$gruppen = [];
|
||||
foreach ($settings['openingHours'] ?? [] as $eintrag) {
|
||||
$schluessel = $eintrag['opens'].'-'.$eintrag['closes'];
|
||||
$gruppen[$schluessel] ??= ['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => [], 'opens' => $eintrag['opens'], 'closes' => $eintrag['closes']];
|
||||
$gruppen[$schluessel]['dayOfWeek'][] = $eintrag['day'];
|
||||
}
|
||||
if ([] !== $gruppen) {
|
||||
$data['openingHoursSpecification'] = array_values($gruppen);
|
||||
}
|
||||
if ([] !== ($settings['areaServed'] ?? [])) {
|
||||
$data['areaServed'] = $settings['areaServed'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
|
||||
/**
|
||||
* Die sozialen Profile einer Website.
|
||||
*
|
||||
* Gepflegt werden sie einmal, in den Einstellungen: je Netzwerk eine Adresse. Der Baustein
|
||||
* `social` zeigt sie dann an beliebig vielen Stellen – im Kopf, im Fuß, in einer Kontaktsektion –
|
||||
* und bestimmt dort nur noch Größe, Form und Beschriftung. Eine Adresse, die sich ändert, ändert
|
||||
* sich damit überall auf einmal; das ist der Unterschied zu von Hand gesetzten Symbol-Links.
|
||||
*
|
||||
* Die Liste der Netzwerke ist fest, weil jedes ein gezeichnetes Symbol braucht (siehe
|
||||
* templates/blocks/social-icon.html.twig). Ein Netzwerk, das hier fehlt, hat kein Symbol – ein
|
||||
* freies Feld würde also nur eine leere Fläche erzeugen.
|
||||
*/
|
||||
final class SocialProfiles
|
||||
{
|
||||
/** Schlüssel → Name, in der Reihenfolge, in der die Symbole erscheinen. */
|
||||
public const NETWORKS = [
|
||||
'facebook' => 'Facebook',
|
||||
'instagram' => 'Instagram',
|
||||
'linkedin' => 'LinkedIn',
|
||||
'youtube' => 'YouTube',
|
||||
'tiktok' => 'TikTok',
|
||||
'x' => 'X',
|
||||
'threads' => 'Threads',
|
||||
'pinterest' => 'Pinterest',
|
||||
'xing' => 'Xing',
|
||||
'whatsapp' => 'WhatsApp',
|
||||
'google' => 'Google-Unternehmensprofil',
|
||||
];
|
||||
|
||||
/** Beispieladressen für die Eingabefelder. */
|
||||
public const PLACEHOLDERS = [
|
||||
'facebook' => 'https://www.facebook.com/…',
|
||||
'instagram' => 'https://www.instagram.com/…',
|
||||
'linkedin' => 'https://www.linkedin.com/company/…',
|
||||
'youtube' => 'https://www.youtube.com/@…',
|
||||
'tiktok' => 'https://www.tiktok.com/@…',
|
||||
'x' => 'https://x.com/…',
|
||||
'threads' => 'https://www.threads.net/@…',
|
||||
'pinterest' => 'https://www.pinterest.de/…',
|
||||
'xing' => 'https://www.xing.com/pages/…',
|
||||
'whatsapp' => 'https://wa.me/49…',
|
||||
'google' => 'https://g.page/…',
|
||||
];
|
||||
|
||||
public const SHAPES = ['plain' => 'Nur Symbol', 'circle' => 'Runde Fläche', 'square' => 'Eckige Fläche'];
|
||||
public const LAYOUTS = ['horizontal' => 'Nebeneinander', 'vertical' => 'Untereinander'];
|
||||
|
||||
/**
|
||||
* Prüft die Eingabe der Einstellungen und gibt nur gefüllte, gültige Adressen zurück.
|
||||
*
|
||||
* @param mixed $input das Feld `social` des Formulars: Netzwerk → Adresse
|
||||
*
|
||||
* @return array<string, string>
|
||||
*
|
||||
* @throws InvalidPage
|
||||
*/
|
||||
public static function clean(mixed $input): array
|
||||
{
|
||||
$profiles = [];
|
||||
foreach (self::NETWORKS as $key => $label) {
|
||||
$url = trim((string) (is_array($input) ? ($input[$key] ?? '') : ''));
|
||||
if ('' === $url) {
|
||||
continue;
|
||||
}
|
||||
if (strlen($url) > 400 || !str_starts_with($url, 'https://') || false === filter_var($url, FILTER_VALIDATE_URL) || 1 === preg_match('/[\s"<>\\\\]/', $url)) {
|
||||
throw new InvalidPage(sprintf('Die Adresse für %s muss mit https:// beginnen und eine gültige Adresse sein.', $label));
|
||||
}
|
||||
$profiles[$key] = $url;
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
}
|
||||
@@ -85,12 +85,20 @@ final class Statistics
|
||||
/**
|
||||
* The numbers for a range of days, ending today.
|
||||
*
|
||||
* Neben den Reihen ein paar Summen, die eine Zahl einordnen: der gleich lange Zeitraum davor,
|
||||
* der staerkste Tag, die Wochentage im Schnitt, wie viel ueber Verweise kam und wie oft eine
|
||||
* Adresse ins Leere lief. Alles aus derselben Tabelle, nichts davon braucht ein Merkmal, das
|
||||
* eine Person wiedererkennbar macht.
|
||||
*
|
||||
* @return array{
|
||||
* days: list<array{day: string, count: int}>,
|
||||
* total: int, pages: list<array{path: string, count: int}>,
|
||||
* total: int, previousTotal: int, busiestDay: ?string,
|
||||
* pages: list<array{path: string, count: int}>,
|
||||
* referrers: list<array{host: string, count: int}>,
|
||||
* missing: list<array{path: string, count: int}>,
|
||||
* hours: list<array{hour: int, count: int}>,
|
||||
* weekdays: list<array{weekday: int, count: int, average: float}>,
|
||||
* missingTotal: int, referred: int, pageCount: int,
|
||||
* range: int, from: string, compactedBefore: string, busiest: int
|
||||
* }
|
||||
*/
|
||||
@@ -132,10 +140,40 @@ final class Statistics
|
||||
$hours[(int) $row['hour']] = (int) $row['total'];
|
||||
}
|
||||
|
||||
$sum = fn (string $extra): int => (int) $this->db->fetchOne(
|
||||
'SELECT COALESCE(SUM(count), 0) FROM stat_hit WHERE '.$where.' '.$extra,
|
||||
$params,
|
||||
);
|
||||
// Der gleich lange Zeitraum davor: erst der Vergleich macht aus einer Zahl eine Aussage.
|
||||
$previousTotal = (int) $this->db->fetchOne(
|
||||
'SELECT COALESCE(SUM(count), 0) FROM stat_hit WHERE website_id = ? AND day >= ? AND day < ? AND status = 200',
|
||||
['default', $now->modify(sprintf('-%d days', 2 * $range - 1))->format('Y-m-d'), $from],
|
||||
);
|
||||
$weekdays = array_fill(1, 7, ['count' => 0, 'days' => 0]);
|
||||
$busiestDay = null;
|
||||
foreach ($days as $entry) {
|
||||
$weekday = (int) (new \DateTimeImmutable($entry['day'], new \DateTimeZone('UTC')))->format('N');
|
||||
$weekdays[$weekday]['count'] += $entry['count'];
|
||||
++$weekdays[$weekday]['days'];
|
||||
if ($entry['count'] > 0 && (null === $busiestDay || $entry['count'] > $perDay[$busiestDay])) {
|
||||
$busiestDay = $entry['day'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'days' => $days,
|
||||
'total' => array_sum(array_column($days, 'count')),
|
||||
'previousTotal' => $previousTotal,
|
||||
'busiest' => max(1, max(array_column($days, 'count'))),
|
||||
'busiestDay' => $busiestDay,
|
||||
'weekdays' => array_map(static fn (int $weekday): array => [
|
||||
'weekday' => $weekday,
|
||||
'count' => $weekdays[$weekday]['count'],
|
||||
'average' => $weekdays[$weekday]['days'] > 0 ? round($weekdays[$weekday]['count'] / $weekdays[$weekday]['days'], 1) : 0.0,
|
||||
], range(1, 7)),
|
||||
'missingTotal' => $sum('AND status = 404'),
|
||||
'referred' => $sum("AND status = 200 AND referrer_host <> ''"),
|
||||
'pageCount' => (int) $this->db->fetchOne('SELECT COUNT(DISTINCT path) FROM stat_hit WHERE '.$where.' AND status = 200', $params),
|
||||
'pages' => $rows('path', 'AND status = 200', 'path'),
|
||||
'referrers' => $rows('referrer_host', "AND status = 200 AND referrer_host <> ''", 'referrer_host'),
|
||||
'missing' => $rows('path', 'AND status = 404', 'path'),
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -97,12 +105,15 @@ final class StyleCompiler
|
||||
public function compileDocument(array $document): string
|
||||
{
|
||||
$css = '';
|
||||
$walk = function (array $blocks) use (&$walk, &$css): void {
|
||||
$walk = function (array $blocks, array $parent = []) use (&$walk, &$css): void {
|
||||
foreach ($blocks as $block) {
|
||||
if (is_array($block['style'] ?? null) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', (string) ($block['id'] ?? ''))) {
|
||||
$css .= $this->compileBlock((string) $block['id'], $block['style']);
|
||||
if ('image' === ($block['type'] ?? null)) {
|
||||
$css .= $this->imageAlignment((string) $block['id'], $block['style'], $parent);
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
$walk($block['children'] ?? [], $block);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
@@ -110,6 +121,50 @@ final class StyleCompiler
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* In a normal section/column, align-self cannot position a figure. Align its box
|
||||
* and the possibly smaller image inside it, including linked images. A horizontal
|
||||
* flex group must keep align-self's vertical axis without consuming its main-axis
|
||||
* space with automatic margins. Resolve both styles at each breakpoint so a parent
|
||||
* layout change or "auto" on a phone cannot leave desktop alignment behind.
|
||||
*/
|
||||
private function imageAlignment(string $id, array $style, array $parent): string
|
||||
{
|
||||
$css = $previous = '';
|
||||
$values = $group = [];
|
||||
// These block types render children in an unstyled inner wrapper.
|
||||
$wrapped = in_array($parent['type'] ?? '', ['section', 'accordionitem', 'slider'], true);
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$values = array_replace_recursive($values, $style[$device] ?? []);
|
||||
$group = array_replace_recursive($group, $parent['style'][$device] ?? []);
|
||||
if (!isset($values['alignSelf'])) {
|
||||
continue;
|
||||
}
|
||||
$alignment = match ($values['alignSelf']) {
|
||||
'center' => ['auto', 'auto', 'center'],
|
||||
'flex-start' => ['0', 'auto', 'start'],
|
||||
'flex-end' => ['auto', '0', 'end'],
|
||||
default => null,
|
||||
};
|
||||
$horizontalFlex = !$wrapped
|
||||
&& in_array($group['display'] ?? '', ['flex', 'inline-flex'], true)
|
||||
&& in_array($group['flexDirection'] ?? 'row', ['row', 'row-reverse'], true);
|
||||
[$left, $right, $justify] = $horizontalFlex || null === $alignment
|
||||
? [$values['margin']['left'] ?? '0', $values['margin']['right'] ?? '0', 'auto']
|
||||
: $alignment;
|
||||
$rules = sprintf('#%s{margin-left:%s;margin-right:%s;justify-self:%s}#%s img{margin-left:%s;margin-right:%s}',
|
||||
$id, $left, $right, $justify, $id, $alignment[0] ?? '0', $alignment[1] ?? '0');
|
||||
if ($rules !== $previous) {
|
||||
$css .= isset(self::BREAKPOINTS[$device])
|
||||
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules)
|
||||
: $rules;
|
||||
$previous = $rules;
|
||||
}
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
private function compileBlock(string $id, array $style): string
|
||||
{
|
||||
$css = '';
|
||||
@@ -130,10 +185,27 @@ final class StyleCompiler
|
||||
if ('' !== $images) {
|
||||
$rules .= sprintf('#%s img{%s}', $id, $images);
|
||||
}
|
||||
// Slider colours: on the slider for the script-free markers, and on the control bar
|
||||
// the script places right after it.
|
||||
$controls = $this->declarations($values, 'controls', $device);
|
||||
if ('' !== $controls) {
|
||||
$rules .= sprintf('#%s,#%s + .slider-controls{%s}', $id, $id, $controls);
|
||||
}
|
||||
// Der Menü-Knopf: die Farben liegen am Block, gelten aber dem aufklappbaren Knopf.
|
||||
$burger = $this->declarations($values, 'burger', $device);
|
||||
if ('' !== $burger) {
|
||||
$rules .= sprintf('#%s .menu-burger>summary{%s}', $id, $burger);
|
||||
}
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'self', $device) : '';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,20 @@ final class StyleSchema
|
||||
'auto' => 'Automatisch', 'pointer' => 'Hand', 'default' => 'Pfeil', 'not-allowed' => 'Gesperrt', 'grab' => 'Greifen', 'zoom-in' => 'Lupe',
|
||||
]],
|
||||
'transition' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Übergangsdauer (ms)', 'min' => 0, 'max' => 2000, 'step' => 25],
|
||||
// The slider controls are built by the script next to the slider, so their colours
|
||||
// travel as custom properties onto the slider and its controls (target "controls").
|
||||
// Der Menü-Knopf ist ein eigener Knopf im Menüblock, kein eigener Baustein: seine
|
||||
// Farben liegen deshalb auf dem Block und wirken auf das summary-Element darin.
|
||||
'burgerColor' => ['group' => 'menu', 'type' => 'color', 'css' => 'color', 'label' => 'Menü-Knopf: Schrift und Striche', 'target' => 'burger'],
|
||||
'burgerBackgroundColor' => ['group' => 'menu', 'type' => 'color', 'css' => 'background-color', 'label' => 'Menü-Knopf: Fläche', 'target' => 'burger'],
|
||||
'burgerBorderColor' => ['group' => 'menu', 'type' => 'color', 'css' => 'border-color', 'label' => 'Menü-Knopf: Rahmen', 'target' => 'burger'],
|
||||
'burgerBorderWidth' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-width', 'label' => 'Menü-Knopf: Rahmenstärke', 'target' => 'burger'],
|
||||
'burgerBorderRadius' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Menü-Knopf: Ecken', 'target' => 'burger'],
|
||||
'burgerFontSize' => ['group' => 'menu', 'type' => 'length', 'css' => 'font-size', 'label' => 'Menü-Knopf: Schriftgröße', 'target' => 'burger'],
|
||||
'sliderDotColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot', 'label' => 'Punkte', 'target' => 'controls'],
|
||||
'sliderDotActiveColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot-active', 'label' => 'Aktiver Punkt', 'target' => 'controls'],
|
||||
'sliderControlColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-ink', 'label' => 'Pfeile und Zähler', 'target' => 'controls'],
|
||||
'sliderControlBackground' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-bg', 'label' => 'Hintergrund der Knöpfe', 'target' => 'controls'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -219,6 +233,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'];
|
||||
|
||||
+55
-20
@@ -25,6 +25,25 @@ final class Theme
|
||||
'system-ui' => 'Systemschrift',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fertige Paletten zum Übernehmen. Dieselben fünf Schlüssel wie die Voreinstellung, damit
|
||||
* Grundfarben und Blöcke, die auf die Schlüssel ink oder link zeigen, stimmen nach dem Wechsel weiter.
|
||||
* Die Linkfarbe erreicht auf dem Papierton jeweils mindestens 4,5:1 - eine Palette, die
|
||||
* ihre eigenen Links unlesbar macht, wäre keine Hilfe.
|
||||
*/
|
||||
public const PALETTES = [
|
||||
['name' => 'Wald', 'colors' => ['paper' => '#f6f7f4', 'ink' => '#203b34', 'mint' => '#e4efb9', 'accent' => '#b55b12', 'link' => '#a4510f']],
|
||||
['name' => 'Ozean', 'colors' => ['paper' => '#f5f8fa', 'ink' => '#12283a', 'mint' => '#d6ecf3', 'accent' => '#0e7490', 'link' => '#0b5f78']],
|
||||
['name' => 'Sand', 'colors' => ['paper' => '#faf6ef', 'ink' => '#3b2f24', 'mint' => '#f1e4cf', 'accent' => '#b8672a', 'link' => '#9a5320']],
|
||||
['name' => 'Koralle', 'colors' => ['paper' => '#fffaf8', 'ink' => '#2b2320', 'mint' => '#ffe4dc', 'accent' => '#d9483b', 'link' => '#b83a2f']],
|
||||
['name' => 'Schiefer', 'colors' => ['paper' => '#f4f5f7', 'ink' => '#1f2937', 'mint' => '#e2e8f0', 'accent' => '#4f46e5', 'link' => '#4338ca']],
|
||||
['name' => 'Olive', 'colors' => ['paper' => '#f7f7f2', 'ink' => '#2a2f23', 'mint' => '#e6ead3', 'accent' => '#6b7a2e', 'link' => '#55621f']],
|
||||
['name' => 'Nacht', 'colors' => ['paper' => '#14181d', 'ink' => '#eef1f4', 'mint' => '#262e38', 'accent' => '#7cc4ff', 'link' => '#9ad2ff']],
|
||||
];
|
||||
|
||||
/** Die Namen der Palettenfarben, wie sie in einer neuen Palette heißen. */
|
||||
public const COLOR_NAMES = ['paper' => 'Papier', 'ink' => 'Tinte', 'mint' => 'Mint', 'accent' => 'Akzent', 'link' => 'Links'];
|
||||
|
||||
public const DEFAULTS = [
|
||||
'colors' => [
|
||||
['key' => 'paper', 'name' => 'Papier', 'value' => '#f6f7f4'],
|
||||
@@ -62,6 +81,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 +90,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 +100,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 +113,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 +144,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 +267,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) {
|
||||
@@ -260,6 +288,29 @@ final class Theme
|
||||
}
|
||||
|
||||
/** The stylesheet every page carries: palette variables, font variables and base typography. */
|
||||
/**
|
||||
* Eine Farbe aus den Einstellungen als CSS-Wert: `@schlüssel` wird zur Palettenvariablen,
|
||||
* ein Hexwert bleibt, und leer heißt "nicht gesetzt" - dann gilt der Standard des Stylesheets.
|
||||
* Ein Schlüssel, den die Palette nicht kennt, wird ebenfalls zu null: lieber der Standard als
|
||||
* eine Variable, die auf nichts zeigt.
|
||||
*/
|
||||
public static function cssColor(string $value, array $theme): ?string
|
||||
{
|
||||
if ('' === $value) {
|
||||
return null;
|
||||
}
|
||||
if (!str_starts_with($value, '@')) {
|
||||
return $value;
|
||||
}
|
||||
foreach ($theme['colors'] as $color) {
|
||||
if ($color['key'] === substr($value, 1)) {
|
||||
return sprintf('var(--k-%s)', $color['key']);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function css(array $theme): string
|
||||
{
|
||||
$variables = [];
|
||||
@@ -317,22 +368,6 @@ final class Theme
|
||||
$colour($base['linkColor']),
|
||||
($base['linkUnderline'] ?? true) ? 'underline' : 'none',
|
||||
$headings,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google Fonts stylesheet for every family that comes from there, or null when none does.
|
||||
* Chosen by the user; it means one request to a third party per page view.
|
||||
*/
|
||||
public function googleFontsUrl(array $theme): ?string
|
||||
{
|
||||
$families = [];
|
||||
foreach ($theme['fonts'] as $font) {
|
||||
if ('google' === $font['source'] && '' !== $font['family']) {
|
||||
$families[] = sprintf('family=%s:wght@%s', str_replace(' ', '+', $font['family']), implode(';', $font['weights']));
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $families ? null : 'https://fonts.googleapis.com/css2?'.implode('&', array_unique($families)).'&display=swap';
|
||||
).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -19,7 +20,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class ConsentController extends AbstractController
|
||||
{
|
||||
public function __construct(private Consent $consent, private SiteSettings $settings)
|
||||
public function __construct(private Consent $consent, private SiteSettings $settings, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -46,16 +47,21 @@ final class ConsentController extends AbstractController
|
||||
}
|
||||
// Deliberately without header and footer: this is a page about a decision, not a page of
|
||||
// the website, and it must work even when an area is broken.
|
||||
// Palette und Hinweisfarben wie auf den Seiten, damit die Entscheidung hier aussieht wie
|
||||
// die Frage dort. Das Stylesheet ist ueber seinen Hash erlaubt, nichts weiter.
|
||||
$theme = $this->theme->load();
|
||||
$styles = $this->theme->css($theme).$this->consent->styles($theme);
|
||||
$response = $this->render('consent.html.twig', [
|
||||
'consent' => $this->consent->state($request),
|
||||
'settings' => $this->settings->load(),
|
||||
'pageStyles' => $styles,
|
||||
]);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
// The page carries no embeds and no own styles, so it keeps the strictest policy.
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// The page carries no embeds, so it keeps the strictest policy - plus the one hashed style block.
|
||||
$response->headers->set('Content-Security-Policy', sprintf("default-src 'self'; script-src 'none'; style-src 'self' 'sha256-%s'; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", base64_encode(hash('sha256', $styles, true))));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Controller;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
@@ -28,7 +29,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/%kant.admin_path%')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -127,10 +128,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(),
|
||||
]);
|
||||
}
|
||||
@@ -431,7 +436,7 @@ final class EditorController extends AbstractController
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme)]);
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||
throw new \InvalidArgumentException();
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -12,17 +14,23 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class FormAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private FormRepository $forms)
|
||||
public function __construct(private FormRepository $forms, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
'forms' => $this->forms->all(),
|
||||
'types' => FormRepository::TYPES,
|
||||
'colors' => FormRepository::COLORS,
|
||||
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
|
||||
// Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website und
|
||||
// kennt die Palette, damit eine Farbe gewaehlt statt getippt wird.
|
||||
'schema' => ['colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttons' => $theme['buttons'] ?? ButtonDesign::defaults($theme['base']), 'base' => $theme['base']],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -57,6 +65,8 @@ final class FormAdminController extends AbstractController
|
||||
'subject' => $request->request->getString('subject'),
|
||||
'success' => $request->request->getString('success'),
|
||||
'replyToField' => $request->request->getString('replyToField'),
|
||||
'buttonLabel' => $request->request->getString('buttonLabel'),
|
||||
...array_map(fn (string $field): string => $request->request->getString($field), array_combine(array_keys(FormRepository::COLORS), array_keys(FormRepository::COLORS))),
|
||||
],
|
||||
$request->request->getInt('expectedVersion', -1),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@ final class PageController extends AbstractController
|
||||
private function renderPage(Request $request, array $page, string $path, int $status = 200): Response
|
||||
{
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($status >= 400 ? null : $page);
|
||||
$theme = $this->theme->load();
|
||||
@@ -80,17 +78,28 @@ 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));
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
// 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.
|
||||
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
|
||||
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false);
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
// Die Farben jedes Formulars auf der Seite - nach dem Theme, damit sie es uebersteuern.
|
||||
$forms = $this->formStates($page, $request->query->getString('gesendet'), $request);
|
||||
$styled = [];
|
||||
foreach ($forms as $state) {
|
||||
if (null !== $state['form'] && !isset($styled[$state['form']['slug']])) {
|
||||
$styled[$state['form']['slug']] = true;
|
||||
$styles .= $this->forms->css($state['form'], $theme);
|
||||
}
|
||||
}
|
||||
// Schriften kommen ausschliesslich vom eigenen Server. Fehlen die Dateien, zeigt die
|
||||
// Website die Ersatzschrift des Themes - nie ein Stylesheet von fonts.googleapis.com:
|
||||
// das gaebe die IP-Adresse jedes Besuchers an einen Dritten weiter und haengt den
|
||||
// Seitenaufbau an zwei fremde Verbindungen nacheinander.
|
||||
$localFonts = $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [];
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
|
||||
@@ -107,13 +116,14 @@ 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)),
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'forms' => $forms,
|
||||
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $status >= 400 ? [] : $this->structuredData($page),
|
||||
@@ -128,7 +138,6 @@ final class PageController extends AbstractController
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
], new Response(status: $status));
|
||||
@@ -136,7 +145,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';
|
||||
@@ -146,7 +155,7 @@ final class PageController extends AbstractController
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||||
return $this->shell($response, $wantsScript, $styles, $frames);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,18 +171,15 @@ final class PageController extends AbstractController
|
||||
* Nothing is added to the policy for an embed still waiting
|
||||
* for consent.
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false, array $frameHosts = []): Response
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = []): Response
|
||||
{
|
||||
$styleSources = ["'self'"];
|
||||
if ('' !== $styles) {
|
||||
$styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true)));
|
||||
}
|
||||
// Stylesheets und Schriftdateien kommen nur von hier. Kein fonts.googleapis.com, kein
|
||||
// fonts.gstatic.com - die Regel steht in der Kopfzeile und nicht nur in der Vorlage.
|
||||
$fontSources = ["'self'"];
|
||||
if ($googleFonts) {
|
||||
// Chosen deliberately: the stylesheet comes from Google and the files from its CDN.
|
||||
$styleSources[] = 'https://fonts.googleapis.com';
|
||||
$fontSources[] = 'https://fonts.gstatic.com';
|
||||
}
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
@@ -252,6 +258,29 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return list<array{name: string, href: string}>
|
||||
*/
|
||||
/** @return list<array<string, mixed>> */
|
||||
private function faqEntries(array $blocks): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach ($blocks as $block) {
|
||||
if ('accordion' === ($block['type'] ?? null) && true === ($block['props']['faq'] ?? false)) {
|
||||
foreach ($block['children'] ?? [] as $item) {
|
||||
$texts = array_filter($item['children'] ?? [], static fn (array $child): bool => 'text' === ($child['type'] ?? null));
|
||||
$answer = trim(implode("\n\n", array_map(
|
||||
static fn (array $child): string => trim(html_entity_decode(strip_tags(preg_replace('#<br\s*/?>|</p>#i', "\n", (string) ($child['props']['text'] ?? '')) ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8')),
|
||||
$texts,
|
||||
)));
|
||||
if ('' !== $answer && '' !== trim((string) ($item['props']['title'] ?? ''))) {
|
||||
$entries[] = ['@type' => 'Question', 'name' => $item['props']['title'], 'acceptedAnswer' => ['@type' => 'Answer', 'text' => $answer]];
|
||||
}
|
||||
}
|
||||
}
|
||||
$entries = [...$entries, ...$this->faqEntries($block['children'] ?? [])];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function breadcrumbs(array $page): array
|
||||
{
|
||||
if ($page['path'] === $this->settings->homePath()) {
|
||||
@@ -288,21 +317,6 @@ final class PageController extends AbstractController
|
||||
if (!($settings['scrollTop'] ?? false)) {
|
||||
return '';
|
||||
}
|
||||
$farbe = static function (string $wert) use ($theme): ?string {
|
||||
if ('' === $wert) {
|
||||
return null;
|
||||
}
|
||||
if (!str_starts_with($wert, '@')) {
|
||||
return $wert;
|
||||
}
|
||||
foreach ($theme['colors'] as $eintrag) {
|
||||
if ($eintrag['key'] === substr($wert, 1)) {
|
||||
return sprintf('var(--k-%s)', $eintrag['key']);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
$werte = [sprintf('--kant-top-size:%dpx', (int) ($settings['scrollTopSize'] ?? 48))];
|
||||
$werte[] = sprintf('--kant-top-radius:%s', match ($settings['scrollTopShape'] ?? 'rund') {
|
||||
'eckig' => '0',
|
||||
@@ -311,7 +325,7 @@ final class PageController extends AbstractController
|
||||
});
|
||||
$werte[] = sprintf('--kant-top-after:%dpx', (int) ($settings['scrollTopAfter'] ?? 400));
|
||||
foreach (['scrollTopColor' => '--kant-top-bg', 'scrollTopInk' => '--kant-top-ink'] as $feld => $variable) {
|
||||
$wert = $farbe((string) ($settings[$feld] ?? ''));
|
||||
$wert = Theme::cssColor((string) ($settings[$feld] ?? ''), $theme);
|
||||
if (null !== $wert) {
|
||||
$werte[] = $variable.':'.$wert;
|
||||
}
|
||||
@@ -377,6 +391,12 @@ final class PageController extends AbstractController
|
||||
}
|
||||
$blocks[] = $article;
|
||||
}
|
||||
// Ein als FAQ gekennzeichnetes Akkordeon wird zur FAQPage: die Titel sind die Fragen, die
|
||||
// Textbausteine im Eintrag die Antwort. Nur was auf der Seite steht, steht auch hier.
|
||||
$fragen = $this->faqEntries($page['document']['blocks'] ?? []);
|
||||
if ([] !== $fragen) {
|
||||
$blocks[] = ['@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $fragen];
|
||||
}
|
||||
$trail = $this->breadcrumbs($page);
|
||||
if (count($trail) > 1) {
|
||||
$blocks[] = [
|
||||
@@ -413,6 +433,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 +505,18 @@ 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']);
|
||||
// Palette und Hinweisfarben wie auf jeder Seite - das Archiv trug bisher weder das eine
|
||||
// noch das andere und zeigte den Einwilligungshinweis in den Standardfarben.
|
||||
$theme = $this->theme->load();
|
||||
$styles = $this->theme->css($theme).$this->consent->styles($theme);
|
||||
|
||||
return $this->shell($this->render('archive.html.twig', [
|
||||
'areas' => $this->areas(),
|
||||
'pageStyles' => $styles,
|
||||
'areas' => $areas,
|
||||
'needsScrolled' => $needsScrolled,
|
||||
'navigation' => $this->navigation(''),
|
||||
'term' => $term,
|
||||
'heading' => null === $term ? 'Beiträge' : $term['name'],
|
||||
@@ -475,7 +524,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, $styles, $frames);
|
||||
}
|
||||
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
@@ -35,6 +36,7 @@ final class PartialController extends AbstractController
|
||||
private Theme $theme,
|
||||
private StyleCompiler $styles,
|
||||
private CustomCode $code,
|
||||
private FontLibrary $fonts,
|
||||
private CsrfTokenManagerInterface $csrf,
|
||||
) {
|
||||
}
|
||||
@@ -175,7 +177,7 @@ final class PartialController extends AbstractController
|
||||
'needsTabs' => false,
|
||||
'forms' => [],
|
||||
'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document),
|
||||
'googleFonts' => $this->theme->googleFontsUrl($theme),
|
||||
'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\SocialProfiles;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -47,6 +48,9 @@ final class SettingsController extends AbstractController
|
||||
'images' => $this->media->page(1, false, 96)['items'],
|
||||
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
|
||||
'palette' => $this->theme->load()['colors'],
|
||||
'socialNetworks' => SocialProfiles::NETWORKS,
|
||||
'weekdays' => SiteSettings::DAYS,
|
||||
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
@@ -25,8 +26,14 @@ 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),
|
||||
// Die Vorschau der Schriften nutzt die Dateien, die schon hier liegen.
|
||||
'fontsStylesheet' => $this->fonts->hasStylesheet(),
|
||||
'palettes' => Theme::PALETTES,
|
||||
'colorNames' => Theme::COLOR_NAMES,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -83,7 +90,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());
|
||||
|
||||
|
||||
@@ -51,12 +51,12 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$headers->set('Referrer-Policy', 'same-origin');
|
||||
$headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
|
||||
// The preview must show the same typeface as the published page, so the font hosts are
|
||||
// allowed here as well. Everything else stays as narrow as on the public side.
|
||||
// Die Vorschau zeigt dieselbe Schrift wie die veroeffentlichte Seite - und die liegt,
|
||||
// genau wie dort, auf diesem Server. Fremde Schrifthosts braucht sie nicht.
|
||||
// The nonce only ever adds the one script it names; 'self' keeps applying to everything
|
||||
// else, so nothing is loosened by it.
|
||||
$nonce = $event->getRequest()->attributes->get('csp_nonce');
|
||||
$script = is_string($nonce) && '' !== $nonce ? sprintf("'self' 'nonce-%s'", $nonce) : "'self'";
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self'; font-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,17 @@ use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
|
||||
private ?string $releaseCacheDir = null;
|
||||
|
||||
public function getCacheDir(): string
|
||||
{
|
||||
// A web update still needs its compiled services until the response has finished.
|
||||
// The next release builds its own cache without deleting the running request's files.
|
||||
return $this->releaseCacheDir ??= parent::getCacheDir().'/release-'.substr(
|
||||
hash_file('sha256', $this->getProjectDir().'/VERSION'),
|
||||
0,
|
||||
16,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -218,9 +218,9 @@ final class ArchiveUpdate
|
||||
}
|
||||
$melden($schritte, 'Dateien ersetzen', true, sprintf('%d Einträge ersetzt, var/ und .env unberührt.', $ersetzt));
|
||||
|
||||
// Der Zwischenspeicher gehört zum alten Stand.
|
||||
$this->removeDirectory($this->root.'/var/cache');
|
||||
$melden($schritte, 'Zwischenspeicher', true, 'Geleert - sonst liefe die neue Fassung mit den Wegweisern der alten.');
|
||||
// Never delete compiled services underneath the running kernel. The new kernel uses
|
||||
// a release-specific cache directory; old requests can finish with their own files.
|
||||
$melden($schritte, 'Zwischenspeicher', true, 'Die neue Fassung verwendet einen eigenen Cache; laufende Anfragen behalten ihren bisherigen.');
|
||||
|
||||
// Hier endet dieser Durchgang mit Absicht. Der laufende Prozess hat den alten Code im
|
||||
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
|
||||
@@ -341,19 +341,4 @@ final class ArchiveUpdate
|
||||
|
||||
return $treffer[1];
|
||||
}
|
||||
|
||||
private function removeDirectory(string $pfad): void
|
||||
{
|
||||
if (!is_dir($pfad)) {
|
||||
return;
|
||||
}
|
||||
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||||
if ('.' === $eintrag || '..' === $eintrag) {
|
||||
continue;
|
||||
}
|
||||
$voll = $pfad.'/'.$eintrag;
|
||||
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
|
||||
}
|
||||
@rmdir($pfad);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Ob der Kopfbereich seine Fläche selbst gestaltet.
|
||||
*
|
||||
* Der klebende Kopf malt eine weiße Fläche mit Schattenlinie, damit ein Kopf ohne eigene
|
||||
* Gestaltung beim Scrollen nicht über dem Inhalt durchscheint. Gibt eine Sektion des Kopfbereichs
|
||||
* aber selbst einen Hintergrund vor - normal, beim Überfahren oder beim Scrollen -, lag diese
|
||||
* Fläche dahinter: eine durchsichtige Sektion zeigte Weiß statt Seite, und die Linie ließ sich
|
||||
* weder färben noch abschalten. Dann trägt die Sektion die Fläche allein.
|
||||
*
|
||||
* Eine Funktion statt einer Variablen, weil vier Wege den Kopf ausgeben - Seite, Archiv,
|
||||
* Editor-Vorschau und Bereichs-Vorschau - und keiner davon vergessen werden darf.
|
||||
*/
|
||||
final class HeaderExtension extends AbstractExtension
|
||||
{
|
||||
/** Die Eigenschaften, die eine Fläche ausmachen. Eine Textfarbe allein ist keine. */
|
||||
private const SURFACE = ['backgroundColor', 'gradientFrom', 'gradientTo', 'backgroundImage'];
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [new TwigFunction('kant_header_surface', self::ownSurface(...))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur Sektionen der obersten Ebene zählen: eine Spalte mit weißem Kasten um das Logo macht
|
||||
* noch keinen Kopf, und ohne Fläche dahinter schiene der Rest über dem Inhalt durch.
|
||||
*/
|
||||
public static function ownSurface(?array $document): bool
|
||||
{
|
||||
foreach ($document['blocks'] ?? [] as $block) {
|
||||
if ('section' !== ($block['type'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($block['style'] ?? [] as $values) {
|
||||
if (!is_array($values)) {
|
||||
continue;
|
||||
}
|
||||
foreach ([$values, $values['hover'] ?? [], $values['scrolled'] ?? []] as $state) {
|
||||
foreach (self::SURFACE as $key) {
|
||||
if (is_array($state) && '' !== (string) ($state[$key] ?? '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,86 @@
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Ops\Updater;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* The running version, for the administration shell.
|
||||
* Fassung und offene Hinweise für die Verwaltungshülle.
|
||||
*
|
||||
* A function rather than a global, so the templates get exactly this one read-only answer and not
|
||||
* a whole updater with an update() method on it.
|
||||
* Funktionen statt Variablen, damit die Vorlagen genau diese Antworten bekommen und nicht einen
|
||||
* ganzen Updater mit einem update() daran.
|
||||
*/
|
||||
final class VersionExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private Updater $updater)
|
||||
public function __construct(private Updater $updater, private \Doctrine\DBAL\Connection $db, private FontLibrary $fonts, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
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(...)),
|
||||
new TwigFunction('kant_admin_notices', $this->notices(...)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Was auf jedem Verwaltungsbildschirm oben stehen soll, weil es einen Knopf braucht.
|
||||
*
|
||||
* Keine Zeile davon geht ins Netz: die verfügbare Fassung steht im Protokoll der täglichen
|
||||
* Aufgabe, die fehlenden Schriften stehen als Dateien im Ordner. Ein Hinweis, der bei jedem
|
||||
* Klick eine fremde Verbindung öffnet, wäre die Sorte Hinweis, die man abschaltet.
|
||||
*
|
||||
* @return list<array{kind: string, text: string, route: string, action: string, admin: bool}>
|
||||
*/
|
||||
public function notices(): array
|
||||
{
|
||||
$notices = [];
|
||||
if (null !== $version = $this->available()) {
|
||||
$notices[] = [
|
||||
'kind' => 'update',
|
||||
'text' => sprintf('Fassung %s ist verfügbar; installiert ist %s.', $version, $this->updater->version()),
|
||||
'route' => 'admin_system',
|
||||
'action' => 'Jetzt aktualisieren',
|
||||
'admin' => true,
|
||||
];
|
||||
}
|
||||
try {
|
||||
$missing = $this->fonts->missing($this->theme->load());
|
||||
} catch (\Throwable) {
|
||||
// Eine Installation, die noch auf ihre Migrationen wartet, hat noch kein Theme.
|
||||
$missing = [];
|
||||
}
|
||||
if ([] !== $missing) {
|
||||
$notices[] = [
|
||||
'kind' => 'fonts',
|
||||
'text' => sprintf('Schriftdateien fehlen auf diesem Server: %s. Bis dahin zeigt die Website die Ersatzschrift.', implode(', ', $missing)),
|
||||
'route' => 'admin_theme',
|
||||
'action' => 'Schriften holen',
|
||||
'admin' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $notices;
|
||||
}
|
||||
|
||||
public function available(): ?string
|
||||
{
|
||||
// The daily worker already checks releases. Rendering navigation never contacts Git.
|
||||
try {
|
||||
$message = $this->db->fetchOne('SELECT last_message FROM scheduled_task WHERE name = ?', ['update-check']);
|
||||
if (is_string($message) && 1 === preg_match('/^Fassung (\d+\.\d+\.\d+) ist verfügbar/', $message, $match)
|
||||
&& version_compare($match[1], $this->updater->version(), '>')) {
|
||||
return $match[1];
|
||||
}
|
||||
} catch (\Doctrine\DBAL\Exception) {
|
||||
// An installation still waiting for migrations has no task table yet.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||
nicht mehr. #}
|
||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', '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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+158
-59
@@ -1,77 +1,176 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'forms' %}
|
||||
{# Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website - dafuer braucht die
|
||||
Seite dessen Stylesheet, genau wie die Designseite. #}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/form-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block title %}Formulare{% endblock %}
|
||||
{% block heading %}Formulare{% endblock %}
|
||||
{% block lead %}Felder, Empfänger und Einwilligungstext werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %}
|
||||
{% block lead %}Felder, Empfänger, Einwilligungstext und Aussehen werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %}
|
||||
{% block actions %}<a class="primary" href="#neues-formular">Neues Formular</a>{% endblock %}
|
||||
|
||||
{# Eine Feldkarte. Der Index steht in den Feldnamen; die Vorlage fuer neue Felder traegt an seiner
|
||||
Stelle den Platzhalter __n__, den das Skript beim Einfuegen ersetzt. #}
|
||||
{% macro field(f, n, row, types) %}
|
||||
{% set id = 'f' ~ f ~ '-' ~ n %}
|
||||
<li class="field-card{{ row.label == '' ? ' field-card-new' : '' }}" data-field>
|
||||
<div class="field-card-head">
|
||||
<span class="field-card-index" aria-hidden="true" data-field-index></span>
|
||||
<div class="field-card-title">
|
||||
<label for="{{ id }}-label">Beschriftung</label>
|
||||
<input id="{{ id }}-label" name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" placeholder="{{ row.label == '' ? 'Neues Feld – Beschriftung eingeben' : '' }}" data-field-label>
|
||||
</div>
|
||||
<div class="field-card-type">
|
||||
<label for="{{ id }}-type">Typ</label>
|
||||
<select id="{{ id }}-type" name="fields[{{ n }}][type]" data-field-type>
|
||||
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="field-remove" data-remove-field hidden aria-label="Feld entfernen" title="Feld entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</div>
|
||||
<div class="field-card-body">
|
||||
<div>
|
||||
<label for="{{ id }}-name">Technischer Name</label>
|
||||
<input id="{{ id }}-name" name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" placeholder="vorname" data-field-name>
|
||||
<p class="field-help">Kleinbuchstaben, Ziffern, Unterstrich. Steht in der E-Mail vor der Antwort.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="{{ id }}-help">Hilfetext</label>
|
||||
<input id="{{ id }}-help" name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" placeholder="Steht unter dem Feld" data-field-help>
|
||||
</div>
|
||||
<div data-field-options>
|
||||
<label for="{{ id }}-options">Optionen der Auswahl</label>
|
||||
<input id="{{ id }}-options" name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" placeholder="Beratung, Angebot, Sonstiges" data-field-options-input>
|
||||
<p class="field-help">Durch Komma trennen, mindestens zwei.</p>
|
||||
</div>
|
||||
<div class="field-card-flags">
|
||||
<label class="check-label" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflichtfeld</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as karte %}
|
||||
<div class="form-screens" data-form-schema="{{ schema|json_encode|e('html_attr') }}">
|
||||
{% for form in forms|merge([null]) %}
|
||||
{% set new = form is null %}
|
||||
{% set f = loop.index %}
|
||||
{% set rows = new ? [] : form.fields %}
|
||||
<section class="stack-section">
|
||||
<div class="section-head">
|
||||
<h2>{{ new ? 'Neues Formular' : form.name }}{% if not new %} <span class="count">{{ form.fields|length }} Felder</span>{% endif %}</h2>
|
||||
{% if not new %}<span class="muted">Block-Adresse <code>{{ form.slug }}</code></span>{% endif %}
|
||||
</div>
|
||||
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="form-def card card-body">
|
||||
{% set settings = new ? {} : form.settings %}
|
||||
{% if new %}
|
||||
<details class="card form-new" id="neues-formular"{{ forms is empty ? ' open' : '' }}>
|
||||
<summary>Neues Formular anlegen</summary>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
{% endif %}
|
||||
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="form-def" data-form-editor>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('forms') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ new ? 0 : form.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="name-{{ loop.index }}">Name</label>
|
||||
<input id="name-{{ loop.index }}" name="name" maxlength="120" required value="{{ new ? '' : form.name }}">
|
||||
</div>
|
||||
{% if new %}
|
||||
<div>
|
||||
<label for="slug-new">Adresse</label>
|
||||
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
|
||||
<p class="field-help">Wird im Formularblock eingetragen.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label for="recipient-{{ loop.index }}">Empfänger</label>
|
||||
<input id="recipient-{{ loop.index }}" name="recipient" type="email" required value="{{ new ? '' : form.settings.recipient }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject-{{ loop.index }}">Betreff</label>
|
||||
<input id="subject-{{ loop.index }}" name="subject" maxlength="160" value="{{ new ? '' : form.settings.subject }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="reply-{{ loop.index }}">Antwortadresse aus Feld</label>
|
||||
<input id="reply-{{ loop.index }}" name="replyToField" maxlength="32" value="{{ new ? '' : form.settings.replyToField }}" placeholder="email">
|
||||
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<label for="success-{{ loop.index }}">Meldung nach dem Absenden</label>
|
||||
<textarea id="success-{{ loop.index }}" name="success" maxlength="300" rows="3" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ new ? '' : form.settings.success }}</textarea>
|
||||
</div>
|
||||
{% if new %}
|
||||
{# Die Zusammenfassung des Details ist keine Ueberschrift; ohne diese springt die
|
||||
Gliederung von der Seitenueberschrift direkt zu den Abschnittstiteln. #}
|
||||
<h2 class="visually-hidden">Neues Formular</h2>
|
||||
{% else %}
|
||||
<div class="card-head">
|
||||
<h2>{{ form.name }} <span class="count">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}</span></h2>
|
||||
<span class="muted">Im Editor als Formularblock mit der Adresse <code>{{ form.slug }}</code></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body form-editor">
|
||||
<div class="form-editor-main">
|
||||
<h3 class="form-part">Zustellung</h3>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="name-{{ f }}">Name</label>
|
||||
<input id="name-{{ f }}" name="name" maxlength="120" required value="{{ new ? '' : form.name }}" placeholder="Kontakt">
|
||||
</div>
|
||||
{% if new %}
|
||||
<div>
|
||||
<label for="slug-new">Adresse</label>
|
||||
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
|
||||
<p class="field-help">Wird im Formularblock eingetragen. Kleinbuchstaben, Ziffern, Bindestrich.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label for="recipient-{{ f }}">Empfänger</label>
|
||||
<input id="recipient-{{ f }}" name="recipient" type="email" required value="{{ settings.recipient|default('') }}" placeholder="info@beispiel.de">
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject-{{ f }}">Betreff der E-Mail</label>
|
||||
<input id="subject-{{ f }}" name="subject" maxlength="160" value="{{ settings.subject|default('') }}" placeholder="Neue Formularnachricht">
|
||||
</div>
|
||||
<div>
|
||||
<label for="reply-{{ f }}">Antwortadresse aus Feld</label>
|
||||
<input id="reply-{{ f }}" name="replyToField" maxlength="32" value="{{ settings.replyToField|default('') }}" placeholder="email">
|
||||
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<label for="success-{{ f }}">Meldung nach dem Absenden</label>
|
||||
<textarea id="success-{{ f }}" name="success" maxlength="300" rows="2" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ settings.success|default('') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table field-table">
|
||||
<caption class="visually-hidden">Felder des Formulars. Eine Beschriftung zu leeren entfernt das Feld.</caption>
|
||||
<thead><tr><th scope="col">Technischer Name</th><th scope="col">Typ</th><th scope="col">Beschriftung</th><th scope="col">Pflicht</th><th scope="col">Hilfetext</th><th scope="col">Optionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows|merge([{name: '', type: 'text', label: '', required: false, help: '', options: []}, {name: '', type: 'text', label: '', required: false, help: '', options: []}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.label == '' %} class="table-new"{% endif %}>
|
||||
<td data-label="Technischer Name"><input name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" aria-label="Technischer Name von Feld {{ n + 1 }}" placeholder="vorname"></td>
|
||||
<td data-label="Typ">
|
||||
<select name="fields[{{ n }}][type]" aria-label="Typ von Feld {{ n + 1 }}">
|
||||
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td data-label="Beschriftung"><input name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" aria-label="Beschriftung von Feld {{ n + 1 }}"></td>
|
||||
<td class="check-cell" data-label="Pflichtfeld"><input type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} aria-label="Feld {{ n + 1 }} ist ein Pflichtfeld"></td>
|
||||
<td data-label="Hilfetext"><input name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" aria-label="Hilfetext von Feld {{ n + 1 }}"></td>
|
||||
<td data-label="Optionen"><input name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" aria-label="Optionen von Feld {{ n + 1 }}" placeholder="nur bei Auswahl"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert. Optionen einer Auswahl durch Komma trennen. Die letzten beiden Zeilen sind für neue Felder.</p>
|
||||
<button class="primary" type="submit">{{ new ? 'Formular anlegen' : 'Formular speichern' }}</button>
|
||||
<div class="form-fields-head">
|
||||
<h3 class="form-part">Felder</h3>
|
||||
<button type="button" class="secondary" data-add-field hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Feld hinzufügen</button>
|
||||
</div>
|
||||
<ol class="field-list" data-field-list>
|
||||
{% for row in rows %}{{ karte.field(f, loop.index0, row, types) }}{% endfor %}
|
||||
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}
|
||||
</ol>
|
||||
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}</template>
|
||||
<p class="field-help" data-fields-help>Ein Feld ohne Beschriftung wird nicht gespeichert – so lässt sich ein Feld auch ohne Skript entfernen. Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert.</p>
|
||||
|
||||
<h3 class="form-part">Aussehen</h3>
|
||||
<div class="fields form-look">
|
||||
<div>
|
||||
<label for="buttonLabel-{{ f }}">Beschriftung des Buttons</label>
|
||||
<input id="buttonLabel-{{ f }}" name="buttonLabel" maxlength="40" value="{{ settings.buttonLabel|default('') }}" placeholder="Absenden" data-form-button-label>
|
||||
</div>
|
||||
{% for key, label in {fieldBorderColor: 'Rahmen der Felder', fieldBackgroundColor: 'Hintergrund der Felder', buttonColor: 'Button', buttonInk: 'Buttonschrift'} %}
|
||||
<div>
|
||||
<label for="{{ key }}-{{ f }}">{{ label }}</label>
|
||||
<input id="{{ key }}-{{ f }}" name="{{ key }}" value="{{ settings[key]|default('') }}" placeholder="Wie die Website" data-form-color="{{ key }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field-help">Leer heißt: wie die Website. Sonst eine Farbe aus der Palette oder ein Wert wie <code>#1a2b3c</code>. Form und Größe des Buttons kommen aus dem Design.</p>
|
||||
</div>
|
||||
|
||||
{# Die Vorschau: dieselben Felder, dieselben Farben, der Standardbutton der Website.
|
||||
Ohne Skript steht sie mit den gespeicherten Feldern da; mit Skript folgt sie jeder
|
||||
Eingabe. #}
|
||||
<aside class="form-editor-preview" aria-label="Vorschau des Formulars">
|
||||
<h3 class="form-part">Vorschau</h3>
|
||||
<div class="form-preview" data-form-preview>
|
||||
{% for row in rows %}
|
||||
<div class="form-preview-field">
|
||||
{% if row.type == 'consent' %}
|
||||
<span class="form-preview-check"><span class="form-preview-box" aria-hidden="true"></span>{{ row.label }} *</span>
|
||||
{% else %}
|
||||
<span class="form-preview-label">{{ row.label }}{% if row.required %} *{% endif %}</span>
|
||||
<span class="form-preview-input{{ row.type == 'textarea' ? ' form-preview-area' : '' }}{{ row.type == 'select' ? ' form-preview-select' : '' }}" aria-hidden="true">{{ row.type == 'select' ? '— bitte wählen —' : '' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<span class="cms-button b-desktop-shape-{{ schema.buttons.shape }} b-desktop-appearance-{{ schema.buttons.appearance }} b-desktop-size-{{ schema.buttons.size }}" data-preview-button>{{ settings.buttonLabel|default('Absenden') }}</span>
|
||||
</div>
|
||||
<p class="field-help">Schrift, Abstände und Buttonform kommen aus dem Design der Website.</p>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="card-foot form-editor-foot">
|
||||
<button class="primary" type="submit">{{ new ? 'Formular anlegen' : 'Formular speichern' }}</button>
|
||||
{% if not new %}<span class="muted">Änderungen gelten sofort auf jeder Seite, die dieses Formular zeigt.</span>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% if new %}
|
||||
</details>
|
||||
{% else %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
|
||||
{% if submissions.items is empty %}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4',
|
||||
tasks: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3.5 2.2',
|
||||
users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5',
|
||||
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5'
|
||||
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||
logout: 'M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5m5-12 4 4-4 4m4-4H9',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
remove: 'M5 7h14M9 7V4h6v3m-7 0v13h8V7'
|
||||
} %}
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name]|default(paths.pages) }}"/></svg>
|
||||
@@ -3,7 +3,7 @@
|
||||
<input type="hidden" data-key="level" name="items[{{ n }}][level]" value="{{ row.level }}">
|
||||
<input type="hidden" data-key="type" name="items[{{ n }}][type]" value="{{ row.type }}">
|
||||
<details>
|
||||
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="muted">{{ row.type == 'page' ? 'Inhalt' : 'Eigener Link' }}</span></summary>
|
||||
<summary><button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg></button><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<div class="menu-entry-fields">
|
||||
<label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}">
|
||||
{% if row.type == 'page' %}
|
||||
@@ -13,6 +13,7 @@
|
||||
<label for="menu-url-{{ n }}">Adresse</label><input id="menu-url-{{ n }}" data-key="url" name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt">
|
||||
{% endif %}
|
||||
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
|
||||
<div class="menu-icon-fields"><div><label for="menu-icon-{{ n }}">Icon</label><select id="menu-icon-{{ n }}" data-key="icon" name="items[{{ n }}][icon]"><option value="">Kein Icon</option>{% for icon in menuIcons %}<option value="{{ icon }}"{{ row.icon|default('') == icon ? ' selected' : '' }}>{{ icon }}</option>{% endfor %}</select></div><div><label for="menu-iconPosition-{{ n }}">Icon-Position</label><select id="menu-iconPosition-{{ n }}" data-key="iconPosition" name="items[{{ n }}][iconPosition]"><option value="before"{{ row.iconPosition|default('before') == 'before' ? ' selected' : '' }}>Vor dem Text</option><option value="after"{{ row.iconPosition|default('before') == 'after' ? ' selected' : '' }}>Nach dem Text</option></select></div></div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{% for icon in menuIcons %}<template id="menu-icon-{{ icon }}">{% include 'blocks/icon.html.twig' with {name: icon} only %}</template>{% endfor %}
|
||||
@@ -5,8 +5,9 @@
|
||||
{% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'admin/menu-icons.html.twig' %}
|
||||
<nav class="menu-slots" aria-label="Menü auswählen">{% for key, label in slots %}<a href="{{ path('admin_menus', {slot: key}) }}"{{ key == slot ? ' aria-current="page"' : '' }}>{{ label }}</a>{% endfor %}</nav>
|
||||
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form">
|
||||
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}"><input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
|
||||
<aside class="menu-add" aria-label="Menüeinträge hinzufügen">
|
||||
<section class="card card-body"><h2>Seiten und Beiträge</h2>
|
||||
|
||||
@@ -95,7 +95,35 @@
|
||||
<input id="description" name="description" maxlength="300" value="{{ settings.description }}">
|
||||
<label for="sameAs">Profile</label>
|
||||
<textarea id="sameAs" name="sameAs" rows="3" placeholder="https://www.linkedin.com/company/…">{{ settings.sameAs|join('\n') }}</textarea>
|
||||
<p class="field-help">Eine HTTPS-Adresse je Zeile, höchstens zwanzig.</p>
|
||||
<p class="field-help">Eine HTTPS-Adresse je Zeile, höchstens zwanzig. Die sozialen Profile unten stehen automatisch mit in den strukturierten Daten.</p>
|
||||
<fieldset class="field-set">
|
||||
<legend>Soziale Profile</legend>
|
||||
<p class="field-help">Einmal hier eintragen, dann mit dem Element „Soziale Profile“ an jeder Stelle einsetzen – im Kopf, im Fuß, in einer Kontaktsektion. Größe, Form und Farbe bestimmt das Element. Leere Felder erscheinen nicht.</p>
|
||||
<div class="fields">
|
||||
{% for key, name in socialNetworks %}
|
||||
<div>
|
||||
<label for="social-{{ key }}">{{ name }}</label>
|
||||
<input id="social-{{ key }}" name="social[{{ key }}]" type="url" maxlength="400" value="{{ settings.social[key]|default('') }}" placeholder="{{ socialPlaceholders[key] }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="field-set">
|
||||
<legend>Öffnungszeiten</legend>
|
||||
<p class="field-help">Als Uhrzeit wie 07:30. Ein Tag ohne Angabe gilt als geschlossen. Erscheint als <code>openingHoursSpecification</code> in den strukturierten Daten – der Text auf der Website ist davon unabhängig.</p>
|
||||
<div class="fields">
|
||||
{% for day, label in weekdays %}
|
||||
{% set row = (settings.openingHours|filter(h => h.day == day)|first) ?: {opens: '', closes: ''} %}
|
||||
<div>
|
||||
<label for="hours-{{ day }}-opens">{{ label }}</label>
|
||||
<div class="hours-row"><input id="hours-{{ day }}-opens" name="hours[{{ day }}][opens]" type="time" value="{{ row.opens }}" aria-label="{{ label }}, öffnet"> <span aria-hidden="true">–</span> <input id="hours-{{ day }}-closes" name="hours[{{ day }}][closes]" type="time" value="{{ row.closes }}" aria-label="{{ label }}, schließt"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label for="areaServed">Einsatzgebiet</label>
|
||||
<textarea id="areaServed" name="areaServed" rows="3" placeholder="Baden-Baden Rastatt Karlsruhe">{{ settings.areaServed|join('\n') }}</textarea>
|
||||
<p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p>
|
||||
<label class="check-label" for="localBusiness"><input id="localBusiness" name="localBusiness" type="checkbox" value="1"{{ settings.localBusiness ? ' checked' : '' }}> Als lokales Unternehmen auszeichnen</label>
|
||||
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
|
||||
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
|
||||
@@ -165,6 +193,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>
|
||||
@@ -178,6 +214,50 @@
|
||||
<label for="consentText">Text im Hinweis</label>
|
||||
<textarea id="consentText" name="consentText" rows="3" maxlength="600">{{ settings.consentText }}</textarea>
|
||||
<p class="field-help">Leer lassen für den Standardtext. Er soll sagen, was geladen wird und was ohne Zustimmung passiert – nicht zum Klicken drängen.</p>
|
||||
<fieldset class="field-set">
|
||||
<legend>Aussehen des Hinweises</legend>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="consentLayout">Anordnung</label>
|
||||
<select id="consentLayout" name="consentLayout">
|
||||
<option value="karte"{{ settings.consentLayout == 'karte' ? ' selected' : '' }}>Karte unten in der Mitte</option>
|
||||
<option value="leiste"{{ settings.consentLayout == 'leiste' ? ' selected' : '' }}>Leiste über die volle Breite</option>
|
||||
<option value="ecke"{{ settings.consentLayout == 'ecke' ? ' selected' : '' }}>Kleine Karte rechts unten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="consentShape">Form</label>
|
||||
<select id="consentShape" name="consentShape">
|
||||
<option value="weich"{{ settings.consentShape == 'weich' ? ' selected' : '' }}>Abgerundet</option>
|
||||
<option value="rund"{{ settings.consentShape == 'rund' ? ' selected' : '' }}>Rund</option>
|
||||
<option value="eckig"{{ settings.consentShape == 'eckig' ? ' selected' : '' }}>Eckig</option>
|
||||
</select>
|
||||
<p class="field-help">Gilt für die Fläche und ihre Knöpfe zusammen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="consentTitle">Überschrift</label>
|
||||
<input id="consentTitle" name="consentTitle" maxlength="80" value="{{ settings.consentTitle }}" placeholder="Externe Inhalte und Einwilligung">
|
||||
</div>
|
||||
<div>
|
||||
<label for="consentColor">Farbe der Fläche</label>
|
||||
<input id="consentColor" name="consentColor" value="{{ settings.consentColor }}" placeholder="@paper" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="consentInk">Schriftfarbe</label>
|
||||
<input id="consentInk" name="consentInk" value="{{ settings.consentInk }}" placeholder="@ink" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="consentAccent">Farbe des Knopfes „Alle akzeptieren“</label>
|
||||
<input id="consentAccent" name="consentAccent" value="{{ settings.consentAccent }}" placeholder="@accent" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
|
||||
<p class="field-help">Färbt auch den Streifen oben, das Symbol und die Kästchen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="consentAccentInk">Schrift auf dem Knopf</label>
|
||||
<input id="consentAccentInk" name="consentAccentInk" value="{{ settings.consentAccentInk }}" placeholder="#ffffff" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Leer heißt: Papier, Tinte und Akzent aus dem Design. „Nur notwendige“ bleibt bewusst ein Rahmenknopf in der Schriftfarbe – beide Antworten sind gleich groß, keine wird versteckt.</p>
|
||||
</fieldset>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="privacyPath">Adresse der Datenschutzseite</label>
|
||||
|
||||
@@ -53,13 +53,25 @@
|
||||
</ul>
|
||||
</nav>
|
||||
</details>
|
||||
{# Der Fuss der Leiste: eine Kontokarte mit Namen und Rolle, daneben der Abmeldeknopf als
|
||||
Symbol mit Beschriftung fuer Vorlesesoftware, darunter die Fassung. Vorher standen drei
|
||||
Zeilen lose untereinander - Fassung, Name, ein grauer Knopf; jetzt ist es eine Einheit. #}
|
||||
<div class="shell-foot">
|
||||
<p class="shell-version">Fassung {{ kant_version() }}</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') }}">
|
||||
<button class="subtle" type="submit">Abmelden</button>
|
||||
</form>
|
||||
<div class="shell-account">
|
||||
<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>
|
||||
<span class="shell-user-text"><strong>{{ app.user.userIdentifier }}</strong><span>{{ is_granted('ROLE_ADMIN') ? 'Administrator' : (is_granted('ROLE_PUBLISHER') ? 'Veröffentlicher' : 'Redakteur') }}</span></span>
|
||||
</a>
|
||||
<form action="{{ path('admin_logout') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
||||
<button class="shell-logout" type="submit" title="Abmelden">{% include 'admin/icon.html.twig' with {name: 'logout'} only %}<span class="visually-hidden">Abmelden</span></button>
|
||||
</form>
|
||||
</div>
|
||||
{% set updateVersion = is_granted('ROLE_ADMIN') ? kant_update_version() : null %}
|
||||
<p class="shell-version">
|
||||
{% if is_granted('ROLE_ADMIN') %}<a href="{{ path('admin_system') }}">KantSites {{ kant_version() }}</a>{% else %}<span>KantSites {{ kant_version() }}</span>{% endif %}
|
||||
{% 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 %}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<main id="admin-main" class="shell-main" tabindex="-1">
|
||||
@@ -71,6 +83,15 @@
|
||||
<div class="shell-actions">{% block actions %}{% endblock %}</div>
|
||||
</div>
|
||||
<div class="shell-body stack">
|
||||
{# Offene Handlungen, auf jedem Bildschirm gleich: fehlende Schriften, eine neue Fassung.
|
||||
Sie stehen hier statt nur auf ihrer eigenen Seite - einen Hinweis findet nur, wer schon
|
||||
weiss, wo er steht. Jeder traegt den Knopf, der ihn erledigt. #}
|
||||
{% for hinweis in kant_admin_notices() %}{% if not hinweis.admin or is_granted('ROLE_ADMIN') %}
|
||||
<p class="shell-notice" role="status">
|
||||
<span>{{ hinweis.text }}</span>
|
||||
<a class="secondary" href="{{ path(hinweis.route) }}">{{ hinweis.action }} →</a>
|
||||
</p>
|
||||
{% endif %}{% endfor %}
|
||||
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
|
||||
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
|
||||
{% endfor %}{% endfor %}
|
||||
|
||||
@@ -4,20 +4,61 @@
|
||||
{% block heading %}Statistik{% endblock %}
|
||||
{% block lead %}Gezählt wird auf dem Server, ohne Cookie und ohne IP-Adresse – deshalb braucht diese Messung keine Einwilligung. Zeiten in UTC.{% endblock %}
|
||||
{% block actions %}
|
||||
<nav class="range-switch" aria-label="Zeitraum">
|
||||
{% for days, label in ranges %}
|
||||
<a href="{{ path('admin_statistics', {tage: days}) }}"{{ days == range ? ' aria-current="page"' : '' }}>{{ label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<a class="secondary" href="{{ path('admin_statistics_csv', {tage: range}) }}">Als CSV</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% set perDay = stats.total / stats.range %}
|
||||
{% set delta = stats.previousTotal > 0 ? ((stats.total - stats.previousTotal) / stats.previousTotal * 100)|round : null %}
|
||||
{% set weekdayNames = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'] %}
|
||||
{% set weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
|
||||
|
||||
<nav class="range-switch" aria-label="Zeitraum">
|
||||
{% for days, label in ranges %}
|
||||
<a class="{{ days == range ? 'primary' : 'secondary' }}" href="{{ path('admin_statistics', {tage: days}) }}"{{ days == range ? ' aria-current="page"' : '' }}>{{ label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{# Die Kennzahlen zuerst: was der Zeitraum gebracht hat, und woran man es misst. #}
|
||||
<div class="stat-tiles">
|
||||
<div class="stat-tile stat-tile-main">
|
||||
<span class="stat-label">Seitenaufrufe</span>
|
||||
<strong class="stat-value">{{ stats.total|number_format(0, ',', '.') }}</strong>
|
||||
{% if delta is null %}
|
||||
<span class="stat-note">{{ stats.total > 0 ? 'im Zeitraum davor keine Aufrufe' : 'noch nichts gezählt' }}</span>
|
||||
{% else %}
|
||||
<span class="stat-note stat-delta {{ delta > 0 ? 'stat-up' : (delta < 0 ? 'stat-down' : '') }}">{{ delta > 0 ? '+' : '' }}{{ delta|number_format(0, ',', '.') }} % gegenüber {{ ranges[range]|lower }} davor ({{ stats.previousTotal|number_format(0, ',', '.') }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Im Schnitt je Tag</span>
|
||||
<strong class="stat-value">{{ perDay|number_format(perDay < 10 ? 1 : 0, ',', '.') }}</strong>
|
||||
<span class="stat-note">seit {{ stats.from|date('d.m.Y') }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Stärkster Tag</span>
|
||||
<strong class="stat-value">{{ stats.busiestDay ? stats.busiest|number_format(0, ',', '.') : '–' }}</strong>
|
||||
<span class="stat-note">{{ stats.busiestDay ? stats.busiestDay|date('d.m.Y') ~ ', ' ~ weekdayNames[stats.busiestDay|date('N') - 1] : 'kein Aufruf im Zeitraum' }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Aufgerufene Seiten</span>
|
||||
<strong class="stat-value">{{ stats.pageCount|number_format(0, ',', '.') }}</strong>
|
||||
<span class="stat-note">unterschiedliche Adressen</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Über Verweise</span>
|
||||
<strong class="stat-value">{{ stats.referred|number_format(0, ',', '.') }}</strong>
|
||||
<span class="stat-note">{{ stats.total > 0 ? (stats.referred / stats.total * 100)|round ~ ' % kamen von einer anderen Website' : 'von einer anderen Website' }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Nicht gefunden</span>
|
||||
<strong class="stat-value">{{ stats.missingTotal|number_format(0, ',', '.') }}</strong>
|
||||
<span class="stat-note">Aufrufe einer Adresse ohne Seite</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Seitenaufrufe <span class="count">{{ stats.total }}</span></h2>
|
||||
<span class="muted">ab {{ stats.from|date('d.m.Y') }}{% if stats.total > 0 %} · im Schnitt {{ (stats.total / stats.range)|number_format(stats.total / stats.range < 10 ? 1 : 0, ',', '.') }} am Tag{% endif %}</span>
|
||||
<h2>Verlauf <span class="count">{{ stats.total }}</span></h2>
|
||||
<span class="muted">Aufrufe je Tag, {{ stats.from|date('d.m.') }} bis heute</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.total == 0 %}
|
||||
@@ -25,26 +66,30 @@
|
||||
{% else %}
|
||||
{# Drawn by the server as an SVG: no chart library, no script, and it prints. The bars are
|
||||
coloured by class, not by a style attribute, because inline styles are forbidden here.
|
||||
The numbers themselves are in the table below, which is what a screen reader reads. #}
|
||||
<svg class="chart" viewBox="0 0 {{ stats.days|length * 12 }} 116" role="img"
|
||||
The numbers themselves are in the table below, which is what a screen reader reads.
|
||||
Jeder Balken traegt seinen Tag und seine Zahl als Titel - der Browser zeigt sie beim
|
||||
Ueberfahren, ohne dass ein Skript etwas nachrechnen muss. #}
|
||||
{% set width = stats.days|length * 12 %}
|
||||
<svg class="chart chart-days" viewBox="0 0 {{ width }} 116" role="img"
|
||||
aria-label="{{ stats.total }} Aufrufe zwischen {{ stats.from|date('d.m.Y') }} und heute, höchster Tageswert {{ stats.busiest }}" preserveAspectRatio="none">
|
||||
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="{{ width }}" y2="{{ line }}.5"></line>{% endfor %}
|
||||
{% for entry in stats.days %}
|
||||
{% set height = entry.count > 0 ? (entry.count / stats.busiest * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||||
<rect class="chart-bar{{ entry.day == stats.busiestDay ? ' chart-bar-peak' : '' }}" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ entry.day|date('d.m.Y') }}: {{ entry.count }} {{ entry.count == 1 ? 'Aufruf' : 'Aufrufe' }}</title></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="{{ stats.days|length * 12 }}" y2="100.5"></line>
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="{{ width }}" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>{{ stats.days|first.day|date('d.m.') }}</span><span>höchster Tag: {{ stats.busiest }}</span><span>{{ stats.days|last.day|date('d.m.') }}</span></p>
|
||||
<p class="chart-legend"><span>{{ stats.days|first.day|date('d.m.') }}</span><span>Skala bis {{ stats.busiest }} · stärkster Tag hervorgehoben</span><span>{{ stats.days|last.day|date('d.m.') }}</span></p>
|
||||
|
||||
<details class="chart-table">
|
||||
<summary>Zahlen je Tag</summary>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<caption class="visually-hidden">Aufrufe je Tag im gewählten Zeitraum</caption>
|
||||
<thead><tr><th scope="col">Tag</th><th scope="col">Aufrufe</th></tr></thead>
|
||||
<thead><tr><th scope="col">Tag</th><th scope="col">Wochentag</th><th scope="col">Aufrufe</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in stats.days|reverse %}
|
||||
<tr><td>{{ entry.day|date('d.m.Y') }}</td><td>{{ entry.count }}</td></tr>
|
||||
<tr><td>{{ entry.day|date('d.m.Y') }}</td><td>{{ weekdayShort[entry.day|date('N') - 1] }}</td><td>{{ entry.count }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -55,19 +100,38 @@
|
||||
</div>
|
||||
|
||||
{% if stats.total > 0 %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Tageszeit</h2><span class="muted">Summe je Stunde, nur die letzten {{ compactAfter }} Tage sind stundengenau</span></div>
|
||||
<div class="card-body">
|
||||
<svg class="chart chart-hours" viewBox="0 0 288 116" role="img" aria-label="Verteilung der Aufrufe über die Stunden des Tages" preserveAspectRatio="none">
|
||||
<div class="stats-columns stats-columns-2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Tageszeit</h2><span class="muted">Summe je Stunde</span></div>
|
||||
<div class="card-body">
|
||||
{% set busiestHour = max(stats.hours|map(entry => entry.count)|filter(count => count > 0)|default([1])) %}
|
||||
{% for entry in stats.hours %}
|
||||
{% set height = entry.count > 0 ? (entry.count / busiestHour * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar" x="{{ entry.hour * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="288" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>0 Uhr</span><span>12 Uhr</span><span>23 Uhr</span></p>
|
||||
<p class="field-help">Ältere Tage sind zu einem Tageswert zusammengefasst und erscheinen hier nicht. Das hält die Tabelle klein, kostet aber die Stunde – eine Entscheidung, keine Lücke.</p>
|
||||
<svg class="chart chart-hours" viewBox="0 0 288 116" role="img" aria-label="Verteilung der Aufrufe über die Stunden des Tages" preserveAspectRatio="none">
|
||||
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="288" y2="{{ line }}.5"></line>{% endfor %}
|
||||
{% for entry in stats.hours %}
|
||||
{% set height = entry.count > 0 ? (entry.count / busiestHour * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar{{ entry.count == busiestHour and entry.count > 0 ? ' chart-bar-peak' : '' }}" x="{{ entry.hour * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ entry.hour }} bis {{ entry.hour + 1 }} Uhr: {{ entry.count }} {{ entry.count == 1 ? 'Aufruf' : 'Aufrufe' }}</title></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="288" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>0 Uhr</span><span>6</span><span>12 Uhr</span><span>18</span><span>23 Uhr</span></p>
|
||||
<p class="field-help">Nur die letzten {{ compactAfter }} Tage sind stundengenau; ältere Tage sind zu einem Tageswert zusammengefasst und erscheinen hier nicht. Das hält die Tabelle klein, kostet aber die Stunde – eine Entscheidung, keine Lücke.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Wochentag</h2><span class="muted">Im Schnitt je Tag</span></div>
|
||||
<div class="card-body">
|
||||
{% set busiestWeekday = max(stats.weekdays|map(entry => entry.average)|filter(average => average > 0)|default([1])) %}
|
||||
<svg class="chart chart-weekdays" viewBox="0 0 84 116" role="img" aria-label="Aufrufe je Wochentag im Schnitt" preserveAspectRatio="none">
|
||||
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="84" y2="{{ line }}.5"></line>{% endfor %}
|
||||
{% for entry in stats.weekdays %}
|
||||
{% set height = entry.average > 0 ? (entry.average / busiestWeekday * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar{{ entry.average == busiestWeekday and entry.average > 0 ? ' chart-bar-peak' : '' }}" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ weekdayNames[entry.weekday - 1] }}: im Schnitt {{ entry.average|number_format(1, ',', '.') }} Aufrufe ({{ entry.count }} gesamt)</title></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="84" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend chart-legend-even">{% for name in weekdayShort %}<span>{{ name }}</span>{% endfor %}</p>
|
||||
<p class="field-help">Der Schnitt gleicht aus, dass ein Zeitraum nicht jeden Wochentag gleich oft enthält.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -75,7 +139,7 @@
|
||||
<div class="stats-columns">
|
||||
{% for group in [
|
||||
{title: 'Seiten', hint: 'Meistaufgerufen', rows: stats.pages, key: 'path', link: true},
|
||||
{title: 'Verweise von außen', hint: 'Domain, die verlinkt hat', rows: stats.referrers, key: 'referrer_host', link: false},
|
||||
{title: 'Verweise von außen', hint: 'Websites, die verlinkt haben', rows: stats.referrers, key: 'referrer_host', link: false},
|
||||
{title: 'Nicht gefundene Adressen', hint: 'Kandidaten für eine Weiterleitung', rows: stats.missing, key: 'path', link: false}
|
||||
] %}
|
||||
<div class="card">
|
||||
@@ -84,11 +148,14 @@
|
||||
{% if group.rows is empty %}
|
||||
<p class="muted">Keine Daten im Zeitraum.</p>
|
||||
{% else %}
|
||||
{% set top = (group.rows|first).count %}
|
||||
<ol class="stats-list">
|
||||
{% for row in group.rows %}
|
||||
<li>
|
||||
<span class="stats-rank" aria-hidden="true">{{ loop.index }}</span>
|
||||
{% if group.link %}<a href="{{ row[group.key] }}">{{ row[group.key] }}</a>{% else %}<span>{{ row[group.key] }}</span>{% endif %}
|
||||
<strong>{{ row.count }}</strong>
|
||||
<svg class="stats-share" viewBox="0 0 100 6" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="stats-share-bar" x="0" y="0" width="{{ (row.count / top * 100)|round(1) }}" height="6" rx="3"></rect></svg>
|
||||
<strong>{{ row.count|number_format(0, ',', '.') }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
@@ -98,14 +165,14 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Was diese Zahlen nicht sagen</h2><span class="muted">Ehrlich statt vollständig</span></div>
|
||||
<details class="card">
|
||||
<summary>Was diese Zahlen nicht sagen</summary>
|
||||
<div class="card-body">
|
||||
<p class="muted">Gezählt werden <strong>Aufrufe</strong>, nicht Besucher: ohne Wiedererkennung lässt sich nicht sagen, ob zehn Aufrufe von zehn Personen oder von einer kommen. Es gibt deshalb auch keine Verweildauer, keine Absprungrate, keinen Klickweg durch die Website und kein Land. All das braucht ein Merkmal, das eine Person wiedererkennbar macht – und damit eine Einwilligung.</p>
|
||||
<p class="field-help">Erkennbare Suchmaschinen und Überwachungsdienste werden anhand ihrer Kennung ausgeschlossen. Das ist eine Heuristik: wer sich als Browser ausgibt, wird mitgezählt. Die Zahl ist also „Aufrufe, die sich nicht als Maschine gemeldet haben“, nicht „Menschen“. Erfundene Adressen von Crawlern laufen gesammelt unter <code>(sonstige)</code>, damit die Tabelle nicht mit tausenden Zeilen wächst.</p>
|
||||
<p class="field-help">Aufbewahrt wird {{ keepDays }} Tage, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. Danach wird gelöscht.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -2,7 +2,7 @@
|
||||
{% set nav = 'system' %}
|
||||
{% block title %}System{% endblock %}
|
||||
{% block heading %}System und Update{% endblock %}
|
||||
{% block lead %}Welche Fassung läuft, ob ein Update möglich ist, und der Weg dorthin: sichern, Wartungszustand, Dateien tauschen, migrieren.{% endblock %}
|
||||
{% block lead %}Fassung, Updateweg und Sicherungen dieser Installation.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
@@ -13,13 +13,13 @@
|
||||
<div class="card-body">
|
||||
<dl class="facts">
|
||||
<dt>Fassung</dt><dd><strong>{{ state.version }}</strong></dd>
|
||||
<dt>Adresse der Verwaltung</dt>
|
||||
<dt>Verwaltung</dt>
|
||||
<dd>
|
||||
<code>/{{ adminPath }}</code>
|
||||
{% if adminPath == 'admin' %}
|
||||
<span class="muted">– die Adresse, die jeder Scanner zuerst probiert. Ändern mit <code>php bin/console cms:admin:path <neu></code>; sie steht danach in <code>.env.local</code> und in keiner öffentlichen Datei.</span>
|
||||
<span class="muted">– Standardpfad, den jeder Scanner zuerst probiert. Ändern: <code>php bin/console cms:admin:path <neu></code>.</span>
|
||||
{% else %}
|
||||
<span class="muted">– geändert. Sie wird in der <code>robots.txt</code> nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit <code>php bin/console cms:admin:path admin</code>.</span>
|
||||
<span class="muted">– geändert; steht in keiner öffentlichen Datei. Zurück: <code>php bin/console cms:admin:path admin</code>.</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Datenbank</dt>
|
||||
@@ -28,17 +28,17 @@
|
||||
{% if database.datei %}
|
||||
<code>{{ database.datei }}</code>
|
||||
{% if database.bytes %}<span class="muted">· {{ (database.bytes / 1048576)|number_format(1, ',', '.') }} MB</span>{% endif %}
|
||||
{% if not database.schreibbar %}<br><span class="check-bad"><strong>Nicht beschreibbar.</strong> Nach einem Upload per FTP gehört die Datei dem FTP-Benutzer; PHP läuft als ein anderer. SQLite braucht Schreibrechte auf die Datei <em>und</em> auf den Ordner darüber.</span>{% endif %}
|
||||
{% if not database.schreibbar %}<br><span class="check-bad"><strong>Nicht beschreibbar.</strong> SQLite braucht Schreibrechte auf die Datei <em>und</em> auf den Ordner darüber – nach einem FTP-Upload gehört sie dem FTP-Benutzer.</span>{% endif %}
|
||||
{% else %}
|
||||
<code>{{ database.adresse }}</code>
|
||||
{% endif %}
|
||||
<br><span class="muted">Eingestellt in <code>{{ database.quelle }}</code>. Die Installationskonfiguration steht in <code>.env</code>. Explizite Server-Umgebungsvariablen haben Vorrang.</span>
|
||||
<br><span class="muted">Eingestellt in <code>{{ database.quelle }}</code>; Server-Umgebungsvariablen haben Vorrang.</span>
|
||||
</dd>
|
||||
{% if state.worktree %}
|
||||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||
{% else %}
|
||||
<dt>Eingerichtet über</dt>
|
||||
<dd>das <strong>Releasearchiv</strong> <span class="muted">– kein Git in diesem Verzeichnis, und dafür ist es auch nicht gedacht. Updates laufen über die Karte weiter unten.</span></dd>
|
||||
<dd><strong>Releasearchiv</strong> <span class="muted">– kein Git; Updates laufen über die Karte unten.</span></dd>
|
||||
{% endif %}
|
||||
{% if state.worktree %}
|
||||
<dt>Verfügbar</dt>
|
||||
@@ -55,7 +55,7 @@
|
||||
</dl>
|
||||
|
||||
{% if state.maintenance %}
|
||||
<p class="notice error" role="alert">Die Website antwortet Besuchern gerade mit 503. Wenn das nicht beabsichtigt ist, hebe den Wartungszustand auf – ein gescheitertes Update kann ihn eingeschaltet gelassen haben.</p>
|
||||
<p class="notice error" role="alert">Die Website antwortet Besuchern mit 503. Wenn das nicht beabsichtigt ist, hebe den Wartungszustand auf – ein gescheitertes Update kann ihn eingeschaltet gelassen haben.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="sheet-actions">
|
||||
@@ -74,7 +74,7 @@
|
||||
vollkommen in Ordnung ist. #}
|
||||
{% if state.worktree %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Vorprüfung</h2><span class="muted">Jede Bedingung wird geprüft, nicht angenommen</span></div>
|
||||
<div class="card-head"><h2>Vorprüfung</h2><span class="muted">{{ checks|length }} Bedingungen</span></div>
|
||||
<div class="card-body">
|
||||
<ul class="check-list">
|
||||
{% for check in checks %}
|
||||
@@ -97,9 +97,9 @@
|
||||
<span class="muted">{{ pendingMigrations|length }} Stück</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="notice">Der Code ist neuer als die Datenbank. Das passiert, wenn ein Update von Hand über FTP eingespielt wurde – dann fehlt der Schritt, den sonst der Updater übernimmt.</p>
|
||||
<p class="notice">Der Code ist neuer als die Datenbank – typisch nach einem Update von Hand über FTP.</p>
|
||||
<ul class="log-list">{% for name in pendingMigrations %}<li><code>{{ name }}</code></li>{% endfor %}</ul>
|
||||
<p class="field-help">Lege vorher eine Sicherung an: Migrationen sind nicht umkehrbar, der Rückweg ist die Sicherung.</p>
|
||||
<p class="field-help">Vorher sichern: Migrationen sind nicht umkehrbar, der Rückweg ist die Sicherung.</p>
|
||||
<form action="{{ path('admin_system_migrate') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<button class="primary" type="submit">Migrationen jetzt ausführen →</button>
|
||||
@@ -111,7 +111,7 @@
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}</h2>
|
||||
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung und tauscht die Dateien' }}</span>
|
||||
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung' }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if archivePending %}
|
||||
@@ -122,14 +122,19 @@
|
||||
<button class="primary" type="submit">Migrationen ausführen und freigeben →</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Dieser Weg braucht kein Git: Er lädt das Archiv der neuen Fassung von der Releaseseite, vergleicht die Prüfsumme, sichert Datenbank und Medien, schaltet den Wartungszustand ein und ersetzt die Dateien der Anwendung. <strong>Unberührt bleiben <code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</strong> Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
|
||||
<p class="field-help">Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung – ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.</p>
|
||||
<p class="muted">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungszustand ein, Dateien tauschen. Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
|
||||
{# Das Kleingedruckte steht zugeklappt: Wer aktualisiert, braucht zuerst den Knopf. #}
|
||||
<details class="hint">
|
||||
<summary>Was unberührt bleibt</summary>
|
||||
<p class="field-help"><code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</p>
|
||||
<p class="field-help">Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung.</p>
|
||||
</details>
|
||||
{% if archiveVersion %}
|
||||
<p class="notice done">Verfügbar: <strong>{{ archiveVersion }}</strong> (installiert: {{ state.version }}).</p>
|
||||
{% elseif asked %}
|
||||
<p class="field-help">Die Releaseseite nennt nichts Neueres als {{ state.version }}.</p>
|
||||
{% else %}
|
||||
<p class="field-help"><a href="{{ path('admin_system', {pruefen: 1}) }}">Nachsehen, ob es etwas Neues gibt</a> – das fragt die Releaseseite und dauert einen Moment.</p>
|
||||
<p class="field-help"><a href="{{ path('admin_system', {pruefen: 1}) }}">Nachsehen, ob es etwas Neues gibt</a> – dauert einen Moment.</p>
|
||||
{% endif %}
|
||||
<form action="{{ path('admin_system_archive') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
@@ -150,9 +155,12 @@
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Update einspielen</h2><span class="muted">{{ ready ? 'Vorprüfung bestanden' : 'erst die Vorprüfung erfüllen' }}</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Der Updater sichert zuerst Datenbank und Umgebungsdateien, schaltet den Wartungszustand ein, wechselt auf den Tag, installiert Abhängigkeiten, migriert, räumt den Cache und prüft, ob die Installation antwortet. Scheitert ein Schritt, wird auf den vorherigen Stand zurückgesetzt – und nur wenn die Migration schon begonnen hatte, auch die Datenbank zurückgeschrieben.</p>
|
||||
<p class="field-help"><strong>Der Medienordner wird nicht gesichert.</strong> Er ist zu groß, und kein Update schreibt darin. Ein Update ist keine Datensicherung.</p>
|
||||
<p class="field-help">Auf manchen Hostings bricht ein Browseraufruf nach kurzer Zeit ab. Dann ist <code>php bin/console cms:update</code> der verlässliche Weg – dort gibt es dieselben Schritte mit <code>--check</code>, <code>--dry-run</code> und <code>--to=v1.2.0</code>.</p>
|
||||
<p class="muted">Sichern, Wartungszustand ein, auf den Tag wechseln, Abhängigkeiten, Migrationen, Cache, Antwortprüfung. Scheitert ein Schritt, wird zurückgesetzt – die Datenbank nur, wenn die Migration schon begonnen hatte.</p>
|
||||
<details class="hint">
|
||||
<summary>Zwei Hinweise vorab</summary>
|
||||
<p class="field-help"><strong>Der Medienordner wird nicht gesichert.</strong> Ein Update ist keine Datensicherung.</p>
|
||||
<p class="field-help">Bricht der Browseraufruf auf dem Hosting ab: <code>php bin/console cms:update</code> – dieselben Schritte mit <code>--check</code>, <code>--dry-run</code> und <code>--to=v1.2.0</code>.</p>
|
||||
</details>
|
||||
|
||||
<form action="{{ path('admin_system_update') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
@@ -164,7 +172,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="check-label" for="dryRun"><input id="dryRun" name="dryRun" type="checkbox" value="1" checked> Nur Probelauf</label>
|
||||
<p class="field-help">Prüft alles und verändert nichts. Nimm das Häkchen weg, wenn es wirklich laufen soll.</p>
|
||||
<p class="field-help">Prüft alles und verändert nichts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" type="submit"{{ ready ? '' : ' disabled' }}>Update ausführen</button>
|
||||
@@ -175,7 +183,7 @@
|
||||
|
||||
{% if steps is not empty %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Letzter Durchlauf</h2><span class="muted">Schritt für Schritt</span></div>
|
||||
<div class="card-head"><h2>Letzter Durchlauf</h2><span class="muted">{{ steps|length }} Schritte</span></div>
|
||||
<div class="card-body">
|
||||
<ol class="check-list">
|
||||
{% for step in steps %}
|
||||
@@ -197,31 +205,31 @@
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Sicherungen</h2><span class="muted">{{ backups|length }} vorhanden · var/backups</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Eine Sicherung enthält die Datenbank als vollständige Kopie – nicht als Dateikopie während des Schreibens, die unbrauchbar sein kann – und den Medienordner. <strong>Nicht enthalten sind <code>.env</code>, <code>vendor/</code> und der Quellcode</strong>: die kommen aus dem Repository, und <code>.env</code> enthält das Geheimnis dieser Installation.</p>
|
||||
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore <name></code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Die tägliche Sicherung erledigt der <a href="{{ path('admin_tasks') }}">Aufgabenplan</a> – auch ohne Cronjob.</p>
|
||||
<p class="muted">Eine Sicherung enthält die Datenbank als vollständige Kopie und den Medienordner – nicht <code>.env</code>, <code>vendor/</code> und den Quellcode.</p>
|
||||
<p class="field-help">Zurückspielen nur auf der Konsole: <code>php bin/console cms:restore <name></code>. Die tägliche Sicherung erledigt der <a href="{{ path('admin_tasks') }}">Aufgabenplan</a>.</p>
|
||||
<form action="{{ path('admin_system_backup') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<button class="primary" type="submit">Sicherung jetzt anlegen</button>
|
||||
</form>
|
||||
|
||||
{% if backups is not empty %}
|
||||
<table class="table">
|
||||
<table class="table table-stack">
|
||||
<caption class="visually-hidden">Vorhandene Sicherungen</caption>
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Erstellt</th><th scope="col">Fassung</th><th scope="col">Inhalt</th><th scope="col">Größe</th><th scope="col">Zustand</th></tr></thead>
|
||||
<tbody>
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td><code>{{ backup.name }}</code></td>
|
||||
<td>{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}<br><span class="muted">{{ backup.manifest.anlass }}</span>{% endif %}</td>
|
||||
<td>{{ backup.manifest ? backup.manifest.fassung : '—' }}</td>
|
||||
<td>{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}</td>
|
||||
<td>{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB</td>
|
||||
<td>{% if backup.intact %}<span class="pill pill-live">unversehrt</span>{% else %}<span class="pill pill-off">Prüfsumme falsch</span>{% endif %}</td>
|
||||
<td data-label="Name"><code>{{ backup.name }}</code></td>
|
||||
<td data-label="Erstellt">{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}<br><span class="muted">{{ backup.manifest.anlass }}</span>{% endif %}</td>
|
||||
<td data-label="Fassung">{{ backup.manifest ? backup.manifest.fassung : '—' }}</td>
|
||||
<td data-label="Inhalt">{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}</td>
|
||||
<td data-label="Größe">{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB</td>
|
||||
<td data-label="Zustand">{% if backup.intact %}<span class="pill pill-live">unversehrt</span>{% else %}<span class="pill pill-off">Prüfsumme falsch</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">„Unversehrt“ heißt: die Prüfsumme der gesicherten Datenbank stimmt noch mit dem Manifest überein. Stimmt sie nicht, wird die Sicherung nicht eingespielt.</p>
|
||||
<p class="field-help">„Unversehrt“ heißt: die Prüfsumme stimmt noch. Stimmt sie nicht, wird die Sicherung nicht eingespielt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,9 +240,19 @@
|
||||
{% if state.log is empty %}
|
||||
<p class="muted">Noch kein Update über diesen Weg eingespielt.</p>
|
||||
{% else %}
|
||||
{# Zwanzig Protokollzeilen sind auf einem Handy zwanzig Absätze. Die jüngsten fünf
|
||||
stehen offen, der Rest liegt eine Umdrehung dahinter. #}
|
||||
<ul class="log-list">
|
||||
{% for line in state.log %}<li><code>{{ line }}</code></li>{% endfor %}
|
||||
{% for line in state.log|slice(0, 5) %}<li><code>{{ line }}</code></li>{% endfor %}
|
||||
</ul>
|
||||
{% if state.log|length > 5 %}
|
||||
<details class="hint">
|
||||
<summary>Ältere Einträge ({{ state.log|length - 5 }})</summary>
|
||||
<ul class="log-list">
|
||||
{% for line in state.log|slice(5) %}<li><code>{{ line }}</code></li>{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+233
-115
@@ -1,132 +1,250 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'theme' %}
|
||||
{# Die lokalen Schriftdateien, sofern es sie gibt: dann zeigt die Vorschau jede Google-Schrift so,
|
||||
wie die Website sie ausliefert. Von Google selbst wird hier nichts geladen. #}
|
||||
{% 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() }}">{% if fontsStylesheet %}<link rel="stylesheet" href="/assets/fonts/fonts.css?v={{ kant_version() }}">{% endif %}{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/theme-panel.js?v={{ kant_version() }}"></script><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 %}
|
||||
{% block content %}
|
||||
<form action="{{ path('admin_theme_save') }}" method="post" class="card card-body theme-form">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
|
||||
<div class="section-head"><h2>Farbpalette <span class="count">{{ theme.colors|length }}</span></h2><span class="muted">Beliebig viele eigene Farben</span></div>
|
||||
<table class="table theme-table">
|
||||
<caption class="visually-hidden">Farben der Website. Einen Schlüssel zu leeren entfernt die Farbe.</caption>
|
||||
<thead><tr><th scope="col">Schlüssel</th><th scope="col">Name</th><th scope="col">Farbe</th><th scope="col">Wert</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in theme.colors|merge([{key: '', name: '', value: '#000000'}, {key: '', name: '', value: '#000000'}, {key: '', name: '', value: '#000000'}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.key == '' %} class="table-new"{% endif %}>
|
||||
<td><input name="colors[{{ n }}][key]" maxlength="32" value="{{ row.key }}" pattern="[a-z][a-z0-9-]*" aria-label="Schlüssel von Farbe {{ n + 1 }}" placeholder="akzent"></td>
|
||||
<td><input name="colors[{{ n }}][name]" maxlength="60" value="{{ row.name }}" aria-label="Name von Farbe {{ n + 1 }}" placeholder="Akzent"></td>
|
||||
<td><input type="color" name="colors[{{ n }}][value]" value="{{ row.value }}" aria-label="Farbwähler für Farbe {{ n + 1 }}" class="theme-swatch"></td>
|
||||
<td><code>{{ row.value }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Der Schlüssel wird im Editor als <code>@schlüssel</code> verwendet und bleibt bei Farbänderungen gleich – eine Farbe ändern wirkt überall, wo sie benutzt wird.</p>
|
||||
|
||||
<div class="section-head"><h2>Schriften <span class="count">{{ theme.fonts|length }}</span></h2><span class="muted">Systemschrift oder Google Fonts</span></div>
|
||||
<table class="table theme-table">
|
||||
<caption class="visually-hidden">Schriften der Website. Einen Schlüssel zu leeren entfernt die Schrift.</caption>
|
||||
<thead><tr><th scope="col">Schlüssel</th><th scope="col">Name</th><th scope="col">Quelle</th><th scope="col">Schriftname</th><th scope="col">Ersatz</th><th scope="col">Schnitte</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in theme.fonts|merge([{key: '', name: '', family: '', source: 'google', fallback: 'sans-serif', weights: [400, 700]}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.key == '' %} class="table-new"{% endif %}>
|
||||
<td><input name="fonts[{{ n }}][key]" maxlength="40" value="{{ row.key }}" pattern="[a-z][a-z0-9-]*" aria-label="Schlüssel von Schrift {{ n + 1 }}" placeholder="heading"></td>
|
||||
<td><input name="fonts[{{ n }}][name]" maxlength="60" value="{{ row.name }}" aria-label="Name von Schrift {{ n + 1 }}" placeholder="Überschriften"></td>
|
||||
<td>
|
||||
<select name="fonts[{{ n }}][source]" aria-label="Quelle von Schrift {{ n + 1 }}">
|
||||
<option value="system"{{ row.source == 'system' ? ' selected' : '' }}>Systemschrift</option>
|
||||
<option value="google"{{ row.source == 'google' ? ' selected' : '' }}>Google Fonts</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="fonts[{{ n }}][family]" maxlength="48" value="{{ row.family }}" aria-label="Schriftname von Schrift {{ n + 1 }}" placeholder="Inter"></td>
|
||||
<td>
|
||||
<select name="fonts[{{ n }}][fallback]" aria-label="Ersatzschrift von Schrift {{ n + 1 }}">
|
||||
{% for value, label in fallbacks %}<option value="{{ value }}"{{ row.fallback == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="weights-cell">
|
||||
{% for weight in weights %}
|
||||
<label><input type="checkbox" name="fonts[{{ n }}][weights][]" value="{{ weight }}"{{ weight in row.weights ? ' checked' : '' }}> {{ weight }}</label>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Bei Google Fonts den Namen genau wie dort schreiben, zum Beispiel <code>Inter</code> oder <code>Playfair Display</code>. Je Schnitt wird eine Datei geladen – nur ankreuzen, was wirklich gebraucht wird. <strong>Google Fonts bedeutet eine Anfrage an Google bei jedem Seitenaufruf.</strong></p>
|
||||
|
||||
<div class="section-head"><h2>Grundtypografie</h2><span class="muted">Gilt überall, solange ein Block nichts anderes sagt</span></div>
|
||||
<div class="fields">
|
||||
{# Eine Farbe der Palette als Karte: der Farbwähler ist die Fläche selbst. #}
|
||||
{% macro color(n, row) %}
|
||||
<li class="swatch-card{{ row.key == '' ? ' swatch-card-new' : '' }}" data-color-card>
|
||||
<label class="swatch-well" for="color-{{ n }}-value">
|
||||
<input type="color" id="color-{{ n }}-value" name="colors[{{ n }}][value]" value="{{ row.value }}" class="swatch-input" aria-label="Farbwert von Farbe {{ n }}" data-color-value>
|
||||
<code class="swatch-hex" data-color-hex>{{ row.value }}</code>
|
||||
</label>
|
||||
<div class="swatch-fields">
|
||||
<div>
|
||||
<label for="base-font">Schrift für Fließtext</label>
|
||||
<select id="base-font" name="base[fontFamily]">
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<label for="color-{{ n }}-name">Name</label>
|
||||
<input id="color-{{ n }}-name" name="colors[{{ n }}][name]" maxlength="60" value="{{ row.name }}" placeholder="Akzent" data-color-name>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-heading-font">Schrift für Überschriften</label>
|
||||
<select id="base-heading-font" name="base[headingFontFamily]">
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.headingFontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-size">Grundgröße</label>
|
||||
<input id="base-size" name="base[fontSize]" value="{{ theme.base.fontSize }}" placeholder="18px">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-line">Zeilenhöhe</label>
|
||||
<input id="base-line" name="base[lineHeight]" type="number" step="0.05" min="0.8" max="3" value="{{ theme.base.lineHeight }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-scale">Überschriftenskala</label>
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="check-label" for="base-underline"><input id="base-underline" name="base[linkUnderline]" type="checkbox" value="1"{{ (theme.base.linkUnderline ?? true) ? ' checked' : '' }}> Links im Fließtext unterstreichen</label>
|
||||
<p class="field-help">Aus sieht ruhiger aus, aber dann erkennt man einen Link im Text nur an der Farbe – für Menschen mit Farbsehschwäche also gar nicht. Beim Überfahren erscheint der Strich in beiden Fällen. Menüs, Karten und Schaltflächen sind davon nicht betroffen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
<p class="field-help">Die Breite des Inhalts auf allen Seiten. Abschnitte mit der Inhaltsbreite „Breit“ oder „Randlos“ halten sich nicht daran – das ist der Zweck dieser beiden.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-color">Textfarbe</label>
|
||||
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-bg">Hintergrundfarbe</label>
|
||||
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-link">Linkfarbe</label>
|
||||
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||
<label for="color-{{ n }}-key">Schlüssel</label>
|
||||
<input id="color-{{ n }}-key" name="colors[{{ n }}][key]" maxlength="32" value="{{ row.key }}" pattern="[a-z][a-z0-9\-]*" placeholder="akzent" data-color-key>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
|
||||
<button class="primary" type="submit">Design speichern</button>
|
||||
<button type="button" class="field-remove" data-remove-color hidden aria-label="Farbe entfernen" title="Farbe entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{# Eine Schrift als Karte: oben die Probe in der Schrift selbst, darunter die Einstellungen. #}
|
||||
{% macro font(n, row, fallbacks, weights) %}
|
||||
<li class="font-card{{ row.key == '' ? ' font-card-new' : '' }}" data-font-card>
|
||||
<div class="font-sample" data-font-sample>
|
||||
<span class="font-sample-big" aria-hidden="true">Aa</span>
|
||||
<span class="font-sample-line">
|
||||
<span class="font-sample-text">Zwölf Boxkämpfer jagen Viktor quer über den großen Sylter Deich.</span>
|
||||
<span class="font-sample-weights" data-font-weights>{% for weight in weights %}<span class="font-weight-{{ weight }}"{{ weight in row.weights ? '' : ' hidden' }}>{{ weight }}</span>{% endfor %}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-fields">
|
||||
<div>
|
||||
<label for="font-{{ n }}-name">Name</label>
|
||||
<input id="font-{{ n }}-name" name="fonts[{{ n }}][name]" maxlength="60" value="{{ row.name }}" placeholder="Überschriften" data-font-name>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-key">Schlüssel</label>
|
||||
<input id="font-{{ n }}-key" name="fonts[{{ n }}][key]" maxlength="40" value="{{ row.key }}" pattern="[a-z][a-z0-9\-]*" placeholder="heading" data-font-key>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-source">Quelle</label>
|
||||
<select id="font-{{ n }}-source" name="fonts[{{ n }}][source]" data-font-source>
|
||||
<option value="system"{{ row.source == 'system' ? ' selected' : '' }}>Systemschrift</option>
|
||||
<option value="google"{{ row.source == 'google' ? ' selected' : '' }}>Google Fonts</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-family">Schriftname</label>
|
||||
<input id="font-{{ n }}-family" name="fonts[{{ n }}][family]" maxlength="48" value="{{ row.family }}" placeholder="Inter" data-font-family>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-fallback">Ersatzschrift</label>
|
||||
<select id="font-{{ n }}-fallback" name="fonts[{{ n }}][fallback]" data-font-fallback>
|
||||
{% for value, label in fallbacks %}<option value="{{ value }}"{{ row.fallback == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<fieldset class="font-weights">
|
||||
<legend>Schnitte</legend>
|
||||
{% for weight in weights %}
|
||||
<label class="chip"><input type="checkbox" name="fonts[{{ n }}][weights][]" value="{{ weight }}"{{ weight in row.weights ? ' checked' : '' }} data-font-weight> {{ weight }}</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<button type="button" class="field-remove" data-remove-font hidden aria-label="Schrift entfernen" title="Schrift entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as karte %}
|
||||
{% set emptyColor = {key: '', name: '', value: '#4a7c59'} %}
|
||||
{% set emptyFont = {key: '', name: '', family: '', source: 'google', fallback: 'sans-serif', weights: [400, 700]} %}
|
||||
<form action="{{ path('admin_theme_save') }}" method="post" class="theme-form stack" data-theme-editor data-palettes="{{ palettes|json_encode|e('html_attr') }}" data-color-names="{{ colorNames|json_encode|e('html_attr') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Farbpalette <span class="count" data-color-count>{{ theme.colors|length }}</span></h2>
|
||||
<span class="muted">Jede Farbe hat einen Schlüssel – im Editor als <code>@schlüssel</code>, und eine Änderung hier wirkt überall</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="palette-presets" role="group" aria-label="Fertige Paletten" hidden data-palette-presets>
|
||||
<span class="palette-presets-label">Fertige Palette übernehmen</span>
|
||||
<div class="palette-preset-row">
|
||||
{% for preset in palettes %}
|
||||
<button type="button" class="palette-preset" data-palette-preset="{{ loop.index0 }}" aria-label="Palette {{ preset.name }} übernehmen">
|
||||
<svg viewBox="0 0 50 14" aria-hidden="true" focusable="false">{% for key, value in preset.colors %}<rect x="{{ loop.index0 * 10 }}" y="0" width="10" height="14" fill="{{ value }}"></rect>{% endfor %}</svg>
|
||||
<span>{{ preset.name }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field-help" data-palette-status role="status">Ersetzt die fünf Grundfarben; eigene Farben bleiben. Gilt erst nach dem Speichern.</p>
|
||||
</div>
|
||||
<ul class="swatch-list" data-color-list>
|
||||
{% for row in theme.colors|merge([emptyColor, emptyColor]) %}{{ karte.color(loop.index0, row) }}{% endfor %}
|
||||
</ul>
|
||||
<template data-color-template>{{ karte.color('__n__', emptyColor) }}</template>
|
||||
<div class="list-actions">
|
||||
<button type="button" class="secondary" data-add-color hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Farbe hinzufügen</button>
|
||||
<p class="field-help">Eine Farbe ohne Schlüssel wird nicht gespeichert – so lässt sich eine Farbe auch ohne Skript entfernen. Die beiden leeren Karten sind für neue Farben.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Schriften <span class="count" data-font-count>{{ theme.fonts|length }}</span></h2>
|
||||
<span class="muted">Systemschrift oder Google Fonts – ausgeliefert vom eigenen Server</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="font-list" data-font-list>
|
||||
{% for row in theme.fonts|merge([emptyFont]) %}{{ karte.font(loop.index0, row, fallbacks, weights) }}{% endfor %}
|
||||
</ul>
|
||||
<template data-font-template>{{ karte.font('__n__', emptyFont, fallbacks, weights) }}</template>
|
||||
<div class="list-actions">
|
||||
<button type="button" class="secondary" data-add-font hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Schrift hinzufügen</button>
|
||||
<p class="field-help">Bei Google Fonts den Namen genau wie dort schreiben, zum Beispiel <code>Inter</code> oder <code>Playfair Display</code>. Je Schnitt wird eine Datei geladen – nur ankreuzen, was wirklich gebraucht wird. {% if fontsStylesheet %}Die Probe nutzt die Schriftdateien auf diesem Server; eine neue Schrift erscheint darin erst, wenn sie unten geholt wurde.{% else %}Die Probe zeigt eine Google-Schrift erst, wenn sie unten auf diesen Server geholt wurde.{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Grundtypografie</h2>
|
||||
<span class="muted">Gilt überall, solange ein Block nichts anderes sagt</span>
|
||||
</div>
|
||||
<div class="card-body type-layout">
|
||||
<div class="type-fields">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="base-font">Schrift für Fließtext</label>
|
||||
<select id="base-font" name="base[fontFamily]" data-font-select>
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-heading-font">Schrift für Überschriften</label>
|
||||
<select id="base-heading-font" name="base[headingFontFamily]" data-font-select>
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.headingFontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-size">Grundgröße</label>
|
||||
<input id="base-size" name="base[fontSize]" value="{{ theme.base.fontSize }}" placeholder="18px">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-line">Zeilenhöhe</label>
|
||||
<input id="base-line" name="base[lineHeight]" type="number" step="0.05" min="0.8" max="3" value="{{ theme.base.lineHeight }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-scale">Überschriftenskala</label>
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
<p class="field-help">Abschnitte mit der Inhaltsbreite „Breit“ oder „Randlos“ halten sich nicht daran – das ist der Zweck dieser beiden.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fields type-colors">
|
||||
<div>
|
||||
<label for="base-color">Textfarbe</label>
|
||||
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink" data-base-color>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-bg">Seitenfarbe</label>
|
||||
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper" data-base-color>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-link">Linkfarbe</label>
|
||||
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent" data-base-color>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Aus der Palette wählen oder eine eigene Farbe eintragen. Eine Palettenfarbe folgt späteren Änderungen an der Palette, eine eigene bleibt, wie sie ist.</p>
|
||||
<div class="field-wide">
|
||||
<label class="check-label" for="base-underline"><input id="base-underline" name="base[linkUnderline]" type="checkbox" value="1"{{ (theme.base.linkUnderline ?? true) ? ' checked' : '' }} data-base-underline> Links im Fließtext unterstreichen</label>
|
||||
<p class="field-help">Aus sieht ruhiger aus, aber dann erkennt man einen Link im Text nur an der Farbe – für Menschen mit Farbsehschwäche also gar nicht. Beim Überfahren erscheint der Strich in beiden Fällen. Menüs, Karten und Schaltflächen sind davon nicht betroffen.</p>
|
||||
</div>
|
||||
</div>
|
||||
{# Die Probe: so liest sich die Website mit diesen Werten. Ohne Skript in der Schrift
|
||||
der Verwaltung, mit Skript in Schrift, Größe, Zeilenhöhe und Farben von hier. #}
|
||||
<aside class="type-preview-column" aria-label="Vorschau der Grundtypografie">
|
||||
<h3 class="form-part">Vorschau</h3>
|
||||
<div class="type-preview" data-type-preview>
|
||||
<p class="type-preview-h1" data-type-h1>Überschrift der Seite</p>
|
||||
<p class="type-preview-h2" data-type-h2>Ein Abschnitt darunter</p>
|
||||
<p class="type-preview-p" data-type-p>Fließtext in der Grundgröße, mit <span class="type-preview-link" data-type-link>einem Link im Text</span> und genug Zeilen, um die Zeilenhöhe zu sehen. Gute Typografie fällt nicht auf – sie liest sich einfach leicht.</p>
|
||||
</div>
|
||||
<p class="field-help">Überschriften folgen der Skala: die erste Ebene ist vier Stufen über der Grundgröße, die zweite drei.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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]" data-font-select><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>
|
||||
<div class="theme-save">
|
||||
<button class="primary" type="submit">Design speichern</button>
|
||||
<span class="muted">Gilt sofort auf allen Seiten.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="section-head"><h2>Schriften ausliefern</h2><span class="muted">Von Google oder vom eigenen Server</span></div>
|
||||
{% if fontsMissing is empty and not fontsLocal %}
|
||||
<p class="field-help">Dieses Design nutzt nur Systemschriften – es lädt ohnehin nichts von Google.</p>
|
||||
{% else %}
|
||||
<p class="field-help">
|
||||
{% if fontsLocal %}
|
||||
<strong>Die Schriften liegen auf diesem Server.</strong> Besucher laden nichts von Google, und die Seite baut sich schneller auf, weil eine Kette aus zwei fremden Verbindungen entfällt.
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Schriften ausliefern</h2>
|
||||
<span class="muted">{{ fontsMissing ? fontsMissing|length ~ ' fehlt noch' : 'immer vom eigenen Server' }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if fontsMissing is empty and not fontsLocal %}
|
||||
<p class="muted">Dieses Design nutzt nur Systemschriften – es lädt ohnehin nichts von Google.</p>
|
||||
{% else %}
|
||||
<strong>Die Schriften kommen zurzeit von Google.</strong> Damit erfährt Google die IP-Adresse jedes Besuchers – in Deutschland ein abmahnfähiger Mangel (LG München I, 3 O 17493/20) –, und der Seitenaufbau wartet auf zwei fremde Verbindungen nacheinander. Mit dem Knopf werden die Dateien einmalig hierher geholt; die Lizenz der Schriften erlaubt das ausdrücklich.
|
||||
{% if fontsLocal %}
|
||||
<p class="muted"><strong>Die Schriften liegen auf diesem Server.</strong> Besucher laden nichts von Google, und die Seite baut sich schneller auf.</p>
|
||||
{% else %}
|
||||
<p class="muted"><strong>Die Schriftdateien fehlen auf diesem Server.</strong> Bis sie hier liegen, zeigt die Website die eingestellte Ersatzschrift – von Google wird nichts geladen, weder hier noch beim Besucher.</p>
|
||||
{% endif %}
|
||||
{% if fontsMissing %}<p class="field-help">Noch nicht hier: {{ fontsMissing|join(', ') }}.</p>{% endif %}
|
||||
<form method="post" action="{{ path('admin_theme_fonts') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
<button class="{{ fontsLocal ? 'secondary' : 'primary' }}" type="submit">{{ fontsLocal ? 'Schriften erneut holen' : 'Schriften auf diesen Server holen' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if fontsMissing %}<br>Noch nicht hier: {{ fontsMissing|join(', ') }}.{% endif %}
|
||||
</p>
|
||||
<form method="post" action="{{ path('admin_theme_fonts') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
<button type="submit">{{ fontsLocal ? 'Schriften erneut holen' : 'Schriften auf diesen Server holen' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -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,16 @@
|
||||
<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 pageStyles|default('') %}<style>{{ pageStyles|raw }}</style>{% endif %}
|
||||
{% 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' : '' }}{{ kant_header_surface(areas.header) ? ' has-own-surface' : '' }}">
|
||||
{% 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 %}
|
||||
|
||||
@@ -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,19 @@
|
||||
{% 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>
|
||||
{# Nur Symbol behält den Namen für Vorlesesoftware; sichtbar ist dann nur der Strich. #}
|
||||
{% set burger = props.burgerLabel|default('both') %}
|
||||
<summary>{% if burger != 'text' %}<span class="burger-lines" aria-hidden="true"></span>{% endif %}<span class="burger-text{{ burger == 'icon' ? ' visually-hidden' : '' }}">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 +119,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 +143,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>
|
||||
@@ -165,6 +172,26 @@
|
||||
{% else %}
|
||||
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
|
||||
{% endif %}
|
||||
{% elseif type == 'social' %}
|
||||
{# Die Profile kommen aus den Einstellungen, der Block sagt nur, wie sie aussehen. Eine
|
||||
Liste, weil es eine ist; ohne Beschriftung trägt jeder Link seinen Namen unsichtbar. #}
|
||||
{% set profiles = siteSettings.load().social|default({}) %}
|
||||
{% set chosen = props.networks|default([]) %}
|
||||
{% set shown = [] %}
|
||||
{% for key, name in constant('App\\Content\\SocialProfiles::NETWORKS') %}
|
||||
{% if profiles[key]|default('') and (chosen is empty or key in chosen) %}{% set shown = shown|merge([{key: key, name: name, url: profiles[key]}]) %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if shown is not empty %}
|
||||
<ul id="{{ block.id }}" class="block-social social-{{ props.layout|default('horizontal') }} social-shape-{{ props.shape|default('plain') }} social-size-{{ props.size|default(24) }}{{ props.labels|default(false) ? ' social-labels' : '' }}{{ designClasses }}" aria-label="Soziale Profile">
|
||||
{% for profile in shown %}
|
||||
<li><a href="{{ profile.url }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab is defined ? props.newTab : true} only %}>{% include 'blocks/social-icon.html.twig' with {network: profile.key} only %}<span{{ props.labels|default(false) ? '' : ' class="visually-hidden"' }}>{{ profile.name }}</span></a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% elseif editing|default(false) %}
|
||||
<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Noch keine sozialen Profile eingetragen. Sie stehen unter Einstellungen → Unternehmensdaten → Soziale Profile.</p>
|
||||
{% else %}
|
||||
<!-- Block {{ block.id }} (social): keine Profile eingetragen. -->
|
||||
{% endif %}
|
||||
{% elseif type == 'progress' %}
|
||||
{# A progress bar as a real meter: the number is in the markup, not only in the width of a
|
||||
coloured div, so it is readable without seeing it. #}
|
||||
@@ -182,7 +209,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 +271,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 %}
|
||||
@@ -4,7 +4,7 @@
|
||||
{% elseif sent %}
|
||||
<p class="form-success" role="status">{{ form.settings.success }}</p>
|
||||
{% else %}
|
||||
<form class="block-form" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate>
|
||||
<form class="block-form" data-form="{{ form.slug }}" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate>
|
||||
<input type="hidden" name="page" value="{{ page.path }}">
|
||||
<input type="hidden" name="block" value="{{ blockId }}">
|
||||
<input type="hidden" name="issued" value="{{ issued }}">
|
||||
@@ -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">{{ form.settings.buttonLabel|default('Absenden') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -13,6 +13,9 @@
|
||||
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||
check: 'm4 12.5 5.5 5.5L20 6'
|
||||
check: 'm4 12.5 5.5 5.5L20 6',
|
||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20'
|
||||
} %}
|
||||
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
||||
@@ -0,0 +1,17 @@
|
||||
{# Ein Symbol je Netzwerk, gezeichnet im Strich des übrigen Symbolsatzes (icon.html.twig): keine
|
||||
Markenbilder, keine Schrift, keine fremde Datei. Die Form ist so weit vereinfacht, dass sie
|
||||
als Umriss erkennbar bleibt und in jeder Größe und Farbe funktioniert. #}
|
||||
{% set shapes = {
|
||||
facebook: '<path d="M15.5 4h-2.2A3.8 3.8 0 0 0 9.5 7.8V10H7v3.2h2.5V20h3.3v-6.8h2.4l.5-3.2h-2.9V8.2c0-.6.3-.9.9-.9h2Z"/>',
|
||||
instagram: '<rect x="3.5" y="3.5" width="17" height="17" rx="4.6"/><circle cx="12" cy="12" r="3.9"/><path d="M17 7h.01"/>',
|
||||
linkedin: '<rect x="3.5" y="3.5" width="17" height="17" rx="2.6"/><path d="M7.6 10.4v6.2M7.6 7.5h.01M11.6 16.6v-6.2m0 2.7a2.6 2.6 0 0 1 5.2 0v3.5"/>',
|
||||
youtube: '<rect x="2.5" y="5.5" width="19" height="13" rx="3.6"/><path d="m10.2 9.2 4.8 2.8-4.8 2.8Z"/>',
|
||||
tiktok: '<path d="M14.2 4v9.6a3.6 3.6 0 1 1-3.6-3.6M14.2 4c0 2.6 2.1 4.7 4.7 4.7"/>',
|
||||
x: '<path d="M4.5 4h4l11 16h-4Z"/><path d="M19 4l-6.1 7.1M5 20l6.1-7.1"/>',
|
||||
threads: '<path d="M12.4 3.5A8.5 8.5 0 1 0 19 17.3c1.5-2.2.8-5.4-2.2-6.1-2.3-.6-4.7.4-5 2.6-.3 2.1 1.7 3.4 3.5 2.5 1.7-.8 2.1-3.5 1.1-6.4-.6-1.8-2-2.8-3.8-2.7-1.4.1-2.5.7-3.2 1.7"/>',
|
||||
pinterest: '<path d="M12 3.5a8.5 8.5 0 0 0-3.4 16.3l1.5-5.6M9.4 12.6c-.6-2.6 1-4.8 3.1-4.8 1.9 0 3 1.4 2.6 3.4-.5 2.2-2 3.7-3.5 3.1M10.1 15.5 8.8 20.5"/>',
|
||||
xing: '<path d="M6.5 6.5 9.4 11 6 16.5M18 3l-6.6 11.5L15.5 21"/>',
|
||||
whatsapp: '<path d="M4 20l1.4-4A8.5 8.5 0 1 1 8.6 19.1Z"/><path d="M9.3 9.2c0 3 2.5 5.5 5.5 5.5l1.3-1.4-2-.9-.9.8a4.1 4.1 0 0 1-2.3-2.3l.8-.9-.9-2Z"/>',
|
||||
google: '<path d="M12 12h8.4A8.5 8.5 0 1 1 18 6.1"/>'
|
||||
} %}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">{{ shapes[network]|raw }}</svg>
|
||||
@@ -3,16 +3,24 @@
|
||||
No JavaScript: the public pages run with script-src 'none' by default, and a banner that needs
|
||||
a script would be the one thing that breaks that. Accepting and refusing are the same kind of
|
||||
button, next to each other, with the same weight - a refusal hidden behind a link would not be
|
||||
a free choice. The per-group selection sits in a disclosure so the first view stays readable. #}
|
||||
<section class="consent-bar" role="region" aria-labelledby="consent-heading">
|
||||
<h2 id="consent-heading">Externe Inhalte und Einwilligung</h2>
|
||||
<p>{{ consent.text ?: 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' }}</p>
|
||||
a free choice. The per-group selection sits in a disclosure so the first view stays readable.
|
||||
|
||||
Anordnung, Form, Farben und Ueberschrift kommen aus den Einstellungen: die Anordnung als
|
||||
Klasse, der Rest als Variablen im Kopf der Seite (Consent::styles). #}
|
||||
<section class="consent-bar consent-layout-{{ consent.layout|default('karte') }}" role="region" aria-labelledby="consent-heading">
|
||||
<div class="consent-body">
|
||||
<h2 id="consent-heading"><svg class="consent-icon" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5"/></svg><span>{{ consent.title|default('') ?: 'Externe Inhalte und Einwilligung' }}</span></h2>
|
||||
<p>{{ consent.text ?: 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' }}</p>
|
||||
</div>
|
||||
<form method="post" action="{{ path('consent_save') }}" class="consent-form">
|
||||
<input type="hidden" name="back" value="{{ back }}">
|
||||
<div class="consent-actions">
|
||||
<button class="consent-button" type="submit" name="choice" value="all">Alle akzeptieren</button>
|
||||
<button class="consent-button" type="submit" name="choice" value="none">Nur notwendige</button>
|
||||
<button class="consent-button consent-accept" type="submit" name="choice" value="all">Alle akzeptieren</button>
|
||||
<button class="consent-button consent-decline" type="submit" name="choice" value="none">Nur notwendige</button>
|
||||
</div>
|
||||
{# Eine Zeile: links die Einzelwahl, rechts die Rechtslinks. Aufgeklappt nimmt die
|
||||
Einzelwahl die ganze Breite, die Links rutschen darunter. #}
|
||||
<div class="consent-foot">
|
||||
<details class="consent-details">
|
||||
<summary>Einzeln wählen</summary>
|
||||
<ul class="consent-groups">
|
||||
@@ -27,9 +35,12 @@
|
||||
</ul>
|
||||
<button class="consent-button consent-choice" type="submit" name="choice" value="some">Auswahl speichern</button>
|
||||
</details>
|
||||
{% if consent.privacyPath or consent.imprintPath %}
|
||||
<p class="consent-links">
|
||||
{% if consent.privacyPath %}<a href="/{{ consent.privacyPath }}">Datenschutz</a>{% endif %}
|
||||
{% if consent.imprintPath %} · <a href="/{{ consent.imprintPath }}">Impressum</a>{% endif %}
|
||||
{% if consent.imprintPath %}<a href="/{{ consent.imprintPath }}">Impressum</a>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -7,6 +7,7 @@
|
||||
<meta name="robots" content="noindex, follow">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
|
||||
{% if pageStyles|default('') %}<style>{{ pageStyles|raw }}</style>{% endif %}
|
||||
</head>
|
||||
<body class="kant consent-page">
|
||||
<main id="main" tabindex="-1">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<span class="nav-label">{% if link.icon|default('') and link.iconPosition|default('before') == 'before' %}{% include 'blocks/icon.html.twig' with {name: link.icon} only %}{% endif %}<span>{{ link.label }}</span>{% if link.icon|default('') and link.iconPosition|default('before') == 'after' %}{% include 'blocks/icon.html.twig' with {name: link.icon} only %}{% endif %}</span>
|
||||
@@ -4,14 +4,14 @@
|
||||
<li>
|
||||
{% if link.children is not empty %}
|
||||
<details class="menu-branch">
|
||||
<summary>{{ link.label }}</summary>
|
||||
<summary>{% include 'nav-label.html.twig' with {link: link} only %}</summary>
|
||||
<div class="menu-branch-body">
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{% include 'nav-label.html.twig' with {link: link} only %}</a>
|
||||
{% include 'nav.html.twig' with {links: link.children, level: (level|default(1)) + 1} only %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{% include 'nav-label.html.twig' with {link: link} only %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -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">
|
||||
@@ -32,23 +33,28 @@
|
||||
drei Abfragen trifft zu - und zwar auf dieselbe Variante, die auch die Regel nimmt. #}
|
||||
{% for bild in heroBackground|default([]) %}<link rel="preload" as="image" href="{{ bild.src }}" media="{{ bild.media }}" fetchpriority="high">
|
||||
{% endfor %}
|
||||
{% if localFonts|default([]) %}
|
||||
{# Jeder Schnitt, den das Theme nennt, vorab - und zwar alle. Fehlt einer, laedt er erst,
|
||||
wenn das Stylesheet geparst ist, und die Ueberschrift springt sichtbar von der
|
||||
Ersatzschrift auf die richtige. Schriften kommen ausschliesslich vom eigenen Server;
|
||||
ein Stylesheet von fonts.googleapis.com wird nie verlinkt. #}
|
||||
{% for font in localFonts %}<link rel="preload" href="{{ font }}" as="font" type="font/woff2" crossorigin>{% endfor %}
|
||||
{% endif %}
|
||||
{% if styleBundle|default(null) %}
|
||||
<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 %}
|
||||
{% endif %}
|
||||
{% if localFonts|default([]) %}
|
||||
{# Die wichtigsten Schnitte vorab, damit die erste Zeile in der richtigen Schrift steht. #}
|
||||
{% for font in localFonts %}<link rel="preload" href="{{ font }}" as="font" type="font/woff2" crossorigin>{% endfor %}
|
||||
{% elseif googleFonts|default(null) %}<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="{{ googleFonts }}">{% endif %}
|
||||
{% if hasPosts|default(false) %} <link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
|
||||
{% endif %}
|
||||
{% 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 +77,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' : '' }}{{ kant_header_surface(areas.header) ? ' has-own-surface' : '' }}">
|
||||
{% 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 %}
|
||||
@@ -91,13 +97,15 @@
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
{# Der Knopf steht hinter der Einwilligungsleiste: Solange sie offen ist, setzt er sich per
|
||||
Ankerpositionierung auf ihre Oberkante, und ein Anker muss im Dokument davor stehen. #}
|
||||
{% set oben = siteSettings.load() %}
|
||||
{% if oben.scrollTop|default(false) and not editing|default(false) %}
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. #}
|
||||
<a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><span aria-hidden="true">↑</span></a>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'],
|
||||
|
||||
+358
-32
@@ -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);
|
||||
}
|
||||
|
||||
@@ -809,6 +810,29 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('class="site-header is-sticky"', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kopfbereich mit eigener Fläche bekommt keine zweite dahinter.
|
||||
*
|
||||
* Beim Nachbau der Tagespflege war die Sektion beim Scrollen auf #ffffff00 gestellt und blieb
|
||||
* trotzdem weiß: der klebende Kopf malte seine eigene Fläche und Schattenlinie darunter.
|
||||
*/
|
||||
public function testAHeaderThatPaintsItsOwnSurfaceGetsNoSecondOne(): void
|
||||
{
|
||||
$settings = $this->site->load();
|
||||
$this->site->save([...$settings, 'stickyHeader' => true, 'sameAs' => '', 'consentGroups' => $settings['consentGroups']]);
|
||||
self::assertStringContainsString('class="site-header is-sticky"', $this->request('/')->getContent(), 'Ohne eigene Fläche bleibt die weiße des Kopfes.');
|
||||
|
||||
self::assertTrue(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'style' => ['desktop' => ['scrolled' => ['backgroundColor' => '#ffffff00']]]]]]));
|
||||
self::assertTrue(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'style' => ['mobile' => ['backgroundColor' => '#294655']]]]]));
|
||||
// Eine Textfarbe ist keine Fläche, und ein Kasten in einer Spalte macht noch keinen Kopf.
|
||||
self::assertFalse(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'style' => ['desktop' => ['color' => '#294655']]]]]));
|
||||
self::assertFalse(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'children' => [['id' => 'c', 'type' => 'column', 'style' => ['desktop' => ['backgroundColor' => '#ffffff']]]]]]]));
|
||||
self::assertFalse(\App\Twig\HeaderExtension::ownSurface(null));
|
||||
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||
self::assertStringContainsString('.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}', $css);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, bool}> */
|
||||
public static function imageLinks(): iterable
|
||||
{
|
||||
@@ -1392,27 +1416,37 @@ final class CmsTest extends TestCase
|
||||
self::assertSame('https://fonts.gstatic.com/s/lato/latein.woff2', $faces[0]['url']);
|
||||
self::assertStringContainsString('U+0131', $faces[0]['range']);
|
||||
|
||||
// Ohne heruntergeladene Dateien bleibt es beim Verweis auf Google - und das Theme weiß es.
|
||||
// Ohne heruntergeladene Dateien bleibt es bei der Ersatzschrift - verlinkt wird Google
|
||||
// nie, denn das gäbe die IP jedes Besuchers an einen Dritten weiter.
|
||||
$theme = new Theme($this->db);
|
||||
$gespeichert = $theme->load();
|
||||
$theme->save($gespeichert['colors'], [
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Lato', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'system-ui'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700], 'fallback' => 'system-ui'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700, 900], 'fallback' => 'system-ui'],
|
||||
], $gespeichert['base']);
|
||||
self::assertFalse($fonts->hasLocal($theme->load()));
|
||||
self::assertSame(['Lato 400', 'Lato 700'], $fonts->missing($theme->load()));
|
||||
self::assertSame(['Lato 400', 'Lato 700', 'Lato 900'], $fonts->missing($theme->load()));
|
||||
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
|
||||
self::assertStringContainsString('fonts.googleapis.com', $this->request('/')->getContent());
|
||||
$ohne = $this->request('/');
|
||||
self::assertStringNotContainsString('fonts.googleapis.com', $ohne->getContent());
|
||||
self::assertStringNotContainsString('fonts.gstatic.com', $ohne->getContent());
|
||||
self::assertStringNotContainsString('rel="preload"'." href=\"/assets/fonts", $ohne->getContent());
|
||||
|
||||
// Mit Dateien: kein Wort mehr von Google, dafür die eigenen Dateien und ein Vorabholen.
|
||||
// Mit Dateien: die eigenen Dateien, und zwar **jeder** Schnitt vorab. Fehlte einer, läge
|
||||
// er erst nach dem Stylesheet vor, und die Überschrift spränge sichtbar von der
|
||||
// Ersatzschrift auf die richtige.
|
||||
mkdir($this->path.'-fonts', 0o775, true);
|
||||
foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2'] as $datei) {
|
||||
foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2', 'lato-900-latin.woff2'] as $datei) {
|
||||
file_put_contents($this->path.'-fonts/'.$datei, 'wOF2test');
|
||||
}
|
||||
file_put_contents($this->path.'-fonts/fonts.css', "@font-face{font-family:'Lato'}");
|
||||
self::assertTrue($fonts->hasLocal($theme->load()));
|
||||
self::assertSame(['/assets/fonts/lato-400-latin.woff2', '/assets/fonts/lato-700-latin.woff2'], $fonts->preload($theme->load()));
|
||||
self::assertSame([
|
||||
'/assets/fonts/lato-400-latin.woff2',
|
||||
'/assets/fonts/lato-700-latin.woff2',
|
||||
'/assets/fonts/lato-900-latin.woff2',
|
||||
], $fonts->preload($theme->load()));
|
||||
}
|
||||
|
||||
public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void
|
||||
@@ -1686,15 +1720,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');
|
||||
@@ -1885,6 +1920,27 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('Bewertung: 5 von 5', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedes erlaubte Symbol braucht eine Zeichnung, und die Symbolfarbe muss am SVG ankommen.
|
||||
*
|
||||
* Ohne den ersten Teil rendert ein Name aus der Liste ein leeres `<path d="">`: unsichtbar,
|
||||
* ohne Fehler, erst auf der fertigen Seite bemerkbar. Der zweite Teil sichert die Regel, die
|
||||
* das eingebettete SVG die Farbe seiner Hülle erben lässt - es trägt dieselbe Klasse und
|
||||
* setzte sonst die Akzentfarbe erneut.
|
||||
*/
|
||||
public function testEveryAllowedIconHasADrawingAndTakesItsColourFromTheBlock(): void
|
||||
{
|
||||
$template = (string) file_get_contents(__DIR__.'/../templates/blocks/icon.html.twig');
|
||||
foreach (DocumentValidator::ICONS as $icon) {
|
||||
self::assertMatchesRegularExpression(
|
||||
'/\R\s{4}'.preg_quote($icon, '/').": '[^']{4,}',?\R/",
|
||||
$template,
|
||||
sprintf('Das Symbol „%s“ steht in der Liste, hat aber keine Zeichnung.', $icon),
|
||||
);
|
||||
}
|
||||
self::assertStringContainsString('.block-icon .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{array}>
|
||||
*/
|
||||
@@ -2230,6 +2286,45 @@ 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 testSliderControlsTakeTheirColoursFromTheSliderStyle(): void
|
||||
{
|
||||
$document = self::styledDocument();
|
||||
$document['schemaVersion'] = 22;
|
||||
$document['blocks'][0]['children'][] = ['id' => 'hero-slider', 'type' => 'slider', 'props' => ['autoplay' => 0, 'dots' => true, 'snap' => true, 'desktopVisible' => 1, 'tabletVisible' => 1, 'mobileVisible' => 1], 'style' => ['desktop' => ['sliderDotColor' => '#ffffff', 'sliderControlColor' => '@mint', 'sliderControlBackground' => '#00000066']], 'children' => [
|
||||
['id' => 'slide-1', 'type' => 'column', 'children' => [['id' => 'slide-1-text', 'type' => 'text', 'props' => ['text' => 'Eins']]]],
|
||||
['id' => 'slide-2', 'type' => 'column', 'children' => [['id' => 'slide-2-text', 'type' => 'text', 'props' => ['text' => 'Zwei']]]],
|
||||
]];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
// On the slider for the script-free markers, on the control bar the script adds after it.
|
||||
self::assertStringContainsString('#hero-slider,#hero-slider + .slider-controls{--slider-dot:#ffffff;--slider-ink:var(--k-mint);--slider-bg:#00000066}', $html);
|
||||
}
|
||||
|
||||
public function testInlineStylesheetIsAllowedByItsOwnHashAndNothingElse(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', self::styledDocument(), 0), 1);
|
||||
@@ -2288,7 +2383,7 @@ final class CmsTest extends TestCase
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy(): void
|
||||
public function testThemeDrivesEveryPageAndNoFontIsEverFetchedFromGoogle(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
// Without a stored theme the defaults apply and no third party is contacted.
|
||||
@@ -2309,10 +2404,14 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('--kf-body:"Inter",sans-serif', $html);
|
||||
self::assertStringContainsString('font-size:var(--k-size)', $html);
|
||||
self::assertStringContainsString('--k-container:1100px', $html);
|
||||
self::assertStringContainsString('fonts.googleapis.com/css2?family=Inter:wght@400;700', $html);
|
||||
// Auch eine Google-Familie im Theme verlinkt nichts nach draußen: Solange die Dateien
|
||||
// nicht hier liegen, greift die Ersatzschrift, und die Kopfzeile lässt keinen fremden
|
||||
// Schrifthost zu.
|
||||
self::assertStringNotContainsString('fonts.googleapis.com', $html);
|
||||
$policy = (string) $response->headers->get('Content-Security-Policy');
|
||||
self::assertStringContainsString('https://fonts.googleapis.com', $policy);
|
||||
self::assertStringContainsString("font-src 'self' https://fonts.gstatic.com", $policy);
|
||||
self::assertStringNotContainsString('googleapis.com', $policy);
|
||||
self::assertStringContainsString("font-src 'self';", $policy);
|
||||
self::assertStringContainsString("style-src 'self' 'sha256-", $policy);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2527,7 +2626,12 @@ final class CmsTest extends TestCase
|
||||
$this->menus->save('header', 'Hauptnavigation', [
|
||||
['label' => 'Start', 'type' => 'page', 'pageId' => $start, 'anchor' => '', 'children' => []],
|
||||
], 0);
|
||||
self::assertStringContainsString('<a href="/" aria-current="page">Start</a>', $this->request('/')->getContent());
|
||||
// Seit den Menuesymbolen (1.13.0) steht die Beschriftung in einer eigenen Huelle. Geprueft
|
||||
// wird, was die Aussage traegt: Adresse, Kennzeichnung der aktuellen Seite, Beschriftung.
|
||||
self::assertMatchesRegularExpression(
|
||||
'#<a href="/" aria-current="page">.*?Start.*?</a>#s',
|
||||
(string) $this->request('/')->getContent(),
|
||||
);
|
||||
|
||||
// The root is protected from being renamed or binned while it is the root.
|
||||
foreach ([fn () => $this->pages->movePage($start, 'anders', null, $this->pages->findForEditor($start)['version'], $this->redirects),
|
||||
@@ -2569,7 +2673,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 +2803,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,10 +3400,66 @@ 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'));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, list<string>, list<string>}> */
|
||||
public static function burgerLabels(): iterable
|
||||
{
|
||||
yield 'symbol and text' => ['both', ['<span class="burger-lines"', '<span class="burger-text">Menü'], []];
|
||||
yield 'only text' => ['text', ['<span class="burger-text">Menü'], ['burger-lines']];
|
||||
yield 'only the symbol, the name stays for screen readers' => ['icon', ['<span class="burger-lines"', '<span class="burger-text visually-hidden">Menü'], []];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $enthalten
|
||||
* @param list<string> $fehlen
|
||||
*/
|
||||
#[DataProvider('burgerLabels')]
|
||||
public function testTheMenuButtonCanBeSymbolTextOrBoth(string $variante, array $enthalten, array $fehlen): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit Menü');
|
||||
$document['schemaVersion'] = 16;
|
||||
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger', 'burgerLabel' => $variante]];
|
||||
$this->menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'level' => 1, 'pageId' => 'page-1', 'position' => 10]], 0);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
// Nur der eigene Knopf, nicht der des Kopfbereichs aus der Startvorlage.
|
||||
$crawler = new \Symfony\Component\DomCrawler\Crawler($this->request('/')->getContent());
|
||||
$summary = $crawler->filter('#nav > details.menu-burger > summary')->html();
|
||||
foreach ($enthalten as $teil) {
|
||||
self::assertStringContainsString($teil, $summary);
|
||||
}
|
||||
foreach ($fehlen as $teil) {
|
||||
self::assertStringNotContainsString($teil, $summary);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnInventedMenuButtonVariantIsRefused(): void
|
||||
{
|
||||
$document = DemoContent::starter('Erfunden');
|
||||
$document['schemaVersion'] = 16;
|
||||
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger', 'burgerLabel' => 'gross']];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
/** Die Farben des Menü-Knopfs liegen am Block und müssen am summary ankommen. */
|
||||
public function testTheMenuButtonTakesItsOwnColours(): void
|
||||
{
|
||||
$document = DemoContent::starter('Farbiger Knopf');
|
||||
$document['schemaVersion'] = 16;
|
||||
$document['blocks'][0]['children'][] = [
|
||||
'id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger'],
|
||||
'style' => ['desktop' => ['burgerColor' => '#294655', 'burgerBackgroundColor' => '#ffffff', 'burgerBorderColor' => '#d8e0d1', 'burgerBorderRadius' => '3px', 'burgerFontSize' => '14px']],
|
||||
];
|
||||
$this->menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'level' => 1, 'pageId' => 'page-1', 'position' => 10]], 0);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('#nav .menu-burger>summary{color:#294655;background-color:#ffffff;border-color:#d8e0d1;border-radius:3px;font-size:14px}', $html);
|
||||
}
|
||||
|
||||
public function testAMenuLayoutNeedsSchemaSixteen(): void
|
||||
{
|
||||
$document = DemoContent::starter('Zu alt');
|
||||
@@ -3337,7 +3498,8 @@ final class CmsTest extends TestCase
|
||||
self::assertStringNotContainsString('#2c6b4f', $site, 'Der Einwilligungsdialog nimmt die Farbe der Website.');
|
||||
// Und er lässt sich eigens färben: eine Palettenfarbe mit dem Schlüssel „consent“ gewinnt
|
||||
// über die Primärfarbe, ohne dass es dafür eine weitere Einstellung braucht.
|
||||
self::assertStringContainsString('background:var(--k-consent,var(--accent))', $site);
|
||||
// Der Palettenschlüssel `consent` bleibt die Zwischenstufe hinter der eigenen Einstellung.
|
||||
self::assertStringContainsString('background:var(--kant-consent-accent,var(--k-consent,var(--accent)))', $site);
|
||||
self::assertStringNotContainsString('outline:3px solid #b85c12', $site, 'Auch der Fokusring folgt der Palette.');
|
||||
}
|
||||
|
||||
@@ -3525,6 +3687,133 @@ final class CmsTest extends TestCase
|
||||
yield 'slider missing a device' => ['slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'snap' => true]];
|
||||
}
|
||||
|
||||
public function testSocialProfilesAreMaintainedOnceAndShownWhereverTheBlockStands(): void
|
||||
{
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'sameAs' => 'https://kant-it.de', 'social' => [
|
||||
'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/', 'facebook' => 'https://www.facebook.com/elektrobernhardgmbh',
|
||||
'x' => '',
|
||||
]]);
|
||||
self::assertSame(['facebook' => 'https://www.facebook.com/elektrobernhardgmbh', 'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/'], $this->site->load()['social']);
|
||||
|
||||
$document = DemoContent::starter('Mit Profilen');
|
||||
$document['schemaVersion'] = 23;
|
||||
$document['blocks'][] = ['id' => 'so1', 'type' => 'social', 'props' => ['size' => 32, 'shape' => 'circle', 'layout' => 'horizontal', 'labels' => false, 'networks' => []]];
|
||||
$document['blocks'][] = ['id' => 'so2', 'type' => 'social', 'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'vertical', 'labels' => true, 'networks' => ['instagram'], 'newTab' => false]];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
// Die Reihenfolge ist die der festen Liste, nicht die der Eingabe; jeder Link trägt seinen Namen.
|
||||
self::assertMatchesRegularExpression('#<ul id="so1" class="block-social social-horizontal social-shape-circle social-size-32"[^>]*>.*facebook\.com.*instagram\.com.*</ul>#s', $html);
|
||||
self::assertStringContainsString('<span class="visually-hidden">Facebook</span>', $html);
|
||||
self::assertStringContainsString('href="https://www.facebook.com/elektrobernhardgmbh" target="_blank" rel="noopener"', $html);
|
||||
// Der zweite Block zeigt nur Instagram, beschriftet, im selben Tab.
|
||||
preg_match('#<ul id="so2"[^>]*>(.*?)</ul>#s', $html, $second);
|
||||
self::assertStringNotContainsString('facebook', $second[1]);
|
||||
self::assertStringContainsString('<span>Instagram</span>', $second[1]);
|
||||
self::assertStringNotContainsString('target="_blank"', $second[1]);
|
||||
self::assertStringContainsString('social-labels', $html);
|
||||
// Und die Profile stehen ohne zweite Pflege in den strukturierten Daten.
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'legalName' => 'Elektro Bernhard GmbH', 'sameAs' => 'https://kant-it.de', 'social' => ['facebook' => 'https://www.facebook.com/elektrobernhardgmbh']]);
|
||||
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $this->request('/')->getContent(), $match);
|
||||
self::assertSame(['https://kant-it.de', 'https://www.facebook.com/elektrobernhardgmbh'], json_decode(html_entity_decode($match[1], ENT_QUOTES), true)['sameAs']);
|
||||
// Ohne Profile bleibt die Seite still: kein leerer Baustein, keine Liste.
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home']);
|
||||
self::assertStringNotContainsString('block-social', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array<string, mixed>}> */
|
||||
public static function refusedSocialBlocks(): iterable
|
||||
{
|
||||
yield 'size off the list' => [['size' => 30, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => false, 'networks' => []]];
|
||||
yield 'unknown shape' => [['size' => 24, 'shape' => 'hexagon', 'layout' => 'horizontal', 'labels' => false, 'networks' => []]];
|
||||
yield 'unknown network' => [['size' => 24, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => false, 'networks' => ['myspace']]];
|
||||
yield 'labels as text' => [['size' => 24, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => 'ja', 'networks' => []]];
|
||||
}
|
||||
|
||||
#[DataProvider('refusedSocialBlocks')]
|
||||
public function testASocialBlockOnlyStoresWhatTheRendererKnows(array $props): void
|
||||
{
|
||||
$document = DemoContent::starter('Profile');
|
||||
$document['schemaVersion'] = 23;
|
||||
$document['blocks'][] = ['id' => 'so1', 'type' => 'social', 'props' => $props];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testOpeningHoursAndTheServiceAreaReachTheStructuredData(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$hours = [];
|
||||
foreach (['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] as $day) {
|
||||
$hours[$day] = ['opens' => '07:30', 'closes' => '17:00'];
|
||||
}
|
||||
$hours['Saturday'] = ['opens' => '09:00', 'closes' => '12:00'];
|
||||
$hours['Sunday'] = ['opens' => '', 'closes' => ''];
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'legalName' => 'Elektro Bernhard GmbH', 'street' => 'Ooser Hauptstr. 51', 'city' => 'Baden-Baden', 'localBusiness' => '1',
|
||||
'hours' => $hours, 'areaServed' => "Baden-Baden\nRastatt\n\nKarlsruhe, Offenburg\nRastatt"]);
|
||||
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $this->request('/')->getContent(), $match);
|
||||
$data = json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
|
||||
// Gleiche Zeiten werden zu einer Angabe, der geschlossene Sonntag fehlt, Orte sind eindeutig.
|
||||
self::assertSame([
|
||||
['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], 'opens' => '07:30', 'closes' => '17:00'],
|
||||
['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => ['Saturday'], 'opens' => '09:00', 'closes' => '12:00'],
|
||||
], $data['openingHoursSpecification']);
|
||||
self::assertSame(['Baden-Baden', 'Rastatt', 'Karlsruhe', 'Offenburg'], $data['areaServed']);
|
||||
}
|
||||
|
||||
public function testReSavingLoadedSettingsKeepsHoursAndServiceArea(): void
|
||||
{
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => ['Monday' => ['opens' => '07:30', 'closes' => '17:00']], 'areaServed' => 'Bühl
|
||||
Achern']);
|
||||
// So gibt das Anlegen der Rechtsseiten die Einstellungen zurück: geladen, unverändert.
|
||||
$loaded = $this->site->load();
|
||||
$this->site->save([...$loaded, 'sameAs' => '', 'consentGroups' => $loaded['consentGroups']]);
|
||||
self::assertSame([['day' => 'Monday', 'opens' => '07:30', 'closes' => '17:00']], $this->site->load()['openingHours']);
|
||||
self::assertSame(['Bühl', 'Achern'], $this->site->load()['areaServed']);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array<string, mixed>}> */
|
||||
public static function refusedOpeningHours(): iterable
|
||||
{
|
||||
yield 'closes before opens' => [['Monday' => ['opens' => '17:00', 'closes' => '07:30']]];
|
||||
yield 'not a time' => [['Monday' => ['opens' => '7.30', 'closes' => '17:00']]];
|
||||
yield 'only one half' => [['Monday' => ['opens' => '07:30', 'closes' => '']]];
|
||||
}
|
||||
|
||||
#[DataProvider('refusedOpeningHours')]
|
||||
public function testOpeningHoursMustBeAPairOfTimes(array $hours): void
|
||||
{
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => $hours]);
|
||||
}
|
||||
|
||||
public function testAnAccordionMarkedAsFaqBecomesAFaqPage(): void
|
||||
{
|
||||
$document = DemoContent::starter('Fragen');
|
||||
$document['schemaVersion'] = 23;
|
||||
$item = static fn (string $id, string $q, string $a): array => ['id' => $id, 'type' => 'accordionitem', 'props' => ['title' => $q, 'open' => false], 'children' => '' === $a ? [] : [['id' => $id.'-t', 'type' => 'text', 'props' => ['text' => $a]]]];
|
||||
$document['blocks'][] = ['id' => 'faq', 'type' => 'accordion', 'props' => ['faq' => true], 'children' => [
|
||||
$item('f1', 'Wo sind Sie im Einsatz?', 'In <strong>Baden-Baden</strong> & Umgebung.'),
|
||||
$item('f2', 'Ohne Antwort?', ''),
|
||||
]];
|
||||
$document['blocks'][] = ['id' => 'plain', 'type' => 'accordion', 'children' => [$item('p1', 'Kein FAQ', 'Nur ein Akkordeon.')]];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
preg_match_all('#<script type="application/ld\+json">(.*?)</script>#s', $this->request('/')->getContent(), $matches);
|
||||
$faq = array_values(array_filter(array_map(static fn (string $json): array => json_decode(html_entity_decode($json, ENT_QUOTES), true), $matches[1]), static fn (array $d): bool => 'FAQPage' === $d['@type']));
|
||||
self::assertCount(1, $faq);
|
||||
// Nur das gekennzeichnete Akkordeon, nur Einträge mit Antwort, ohne Auszeichnungen im Text.
|
||||
self::assertSame([['@type' => 'Question', 'name' => 'Wo sind Sie im Einsatz?', 'acceptedAnswer' => ['@type' => 'Answer', 'text' => 'In Baden-Baden & Umgebung.']]], $faq[0]['mainEntity']);
|
||||
// Ohne Schema v23 wird die Kennzeichnung abgewiesen.
|
||||
$document['schemaVersion'] = 22;
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 1);
|
||||
}
|
||||
|
||||
public function testASocialProfileMustBeAnHttpsAddress(): void
|
||||
{
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'social' => ['facebook' => 'http://facebook.com/x']]);
|
||||
}
|
||||
|
||||
#[DataProvider('refusedNewBlocks')]
|
||||
public function testRefusedNewBlocksNeverReachADocument(string $type, array $props): void
|
||||
{
|
||||
@@ -3554,7 +3843,7 @@ final class CmsTest extends TestCase
|
||||
yield 'corners' => [['borderRadius' => ['topLeft' => '18px']], '#b1{border-top-left-radius:18px}'];
|
||||
yield 'outline' => [['outlineWidth' => '3px', 'outlineColor' => '#2c6b4f', 'outlineOffset' => '2px'], '#b1{outline-width:3px;outline-color:#2c6b4f;outline-offset:2px}'];
|
||||
yield 'position and layer' => [['position' => 'absolute', 'top' => '0', 'right' => '12px', 'zIndex' => '5'], '#b1{position:absolute;top:0;right:12px;z-index:5}'];
|
||||
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}'];
|
||||
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}', 'column'];
|
||||
yield 'separate gaps' => [['rowGap' => '8px', 'columnGap' => '24px'], '#b1{row-gap:8px;column-gap:24px}'];
|
||||
yield 'inner shadow' => [['shadowY' => '4px', 'shadowBlur' => '12px', 'shadowInset' => 'innen'], '#b1{box-shadow:inset 0 4px 12px 0 #00000026}'];
|
||||
yield 'outer shadow stays outer' => [['shadowY' => '4px'], '#b1{box-shadow:0 4px 0 0 #00000026}'];
|
||||
@@ -3570,11 +3859,11 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
|
||||
#[DataProvider('styleDeclarations')]
|
||||
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected): void
|
||||
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected, string $type = 'image'): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
self::assertSame($expected, $compiler->compileDocument([
|
||||
'blocks' => [['id' => 'b1', 'type' => 'image', 'style' => ['desktop' => $style]]],
|
||||
'blocks' => [['id' => 'b1', 'type' => $type, 'style' => ['desktop' => $style]]],
|
||||
]));
|
||||
// And what compiles has to survive the validator, or the editor could offer it in vain.
|
||||
$document = DemoContent::starter('Mit Stil');
|
||||
@@ -3593,8 +3882,7 @@ final class CmsTest extends TestCase
|
||||
$panel = (string) file_get_contents(__DIR__.'/../public/assets/design-panel.js');
|
||||
foreach (array_keys($groups) as $group) {
|
||||
// A group the panel does not know would hold its properties in an unnamed accordion.
|
||||
self::assertMatchesRegularExpression('/
|
||||
'.$group.': /', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
|
||||
self::assertMatchesRegularExpression('/^\h*'.preg_quote($group, '/').':\h/m', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3646,6 +3934,44 @@ final class CmsTest extends TestCase
|
||||
self::assertSame(404, $this->request('/einwilligung')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testTheConsentQuestionTakesLayoutShapeAndColoursFromTheSettings(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$this->askConsent();
|
||||
|
||||
// Standard: Karte, abgerundet, Themefarben - im Kopf stehen nur die Radien.
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('consent-layout-karte', $html);
|
||||
self::assertStringContainsString('--kant-consent-radius:18px;--kant-consent-button-radius:10px', $html);
|
||||
self::assertStringNotContainsString('--kant-consent-bg', $html);
|
||||
self::assertStringContainsString('Externe Inhalte und Einwilligung', $html);
|
||||
|
||||
// Eigene Werte: Palette wird zur Variablen, ein unbekannter Schlüssel fällt weg statt
|
||||
// auf nichts zu zeigen, die Überschrift steht escaped im Markup.
|
||||
$this->site->save(['homePath' => 'home', 'consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => 'datenschutz',
|
||||
'consentLayout' => 'leiste', 'consentShape' => 'rund', 'consentTitle' => 'Cookies & Co.',
|
||||
'consentColor' => '#101820', 'consentInk' => '#ffffff', 'consentAccent' => '@ink', 'consentAccentInk' => '@nope']);
|
||||
$response = $this->request('/');
|
||||
$html = $response->getContent();
|
||||
self::assertStringContainsString('consent-layout-leiste', $html);
|
||||
self::assertStringContainsString('--kant-consent-radius:28px;--kant-consent-button-radius:999px;--kant-consent-bg:#101820;--kant-consent-ink:#ffffff;--kant-consent-accent:var(--k-ink)}', $html);
|
||||
self::assertStringNotContainsString('--kant-consent-accent-ink', $html);
|
||||
self::assertStringContainsString('Cookies & Co.', $html);
|
||||
self::assertStringContainsString("style-src 'self' 'sha256-", (string) $response->headers->get('Content-Security-Policy'));
|
||||
|
||||
// Die Einwilligungsseite trägt dieselben Werte, erlaubt per Hash und sonst nichts.
|
||||
$page = $this->request('/einwilligung');
|
||||
self::assertSame(200, $page->getStatusCode());
|
||||
self::assertStringContainsString('--kant-consent-bg:#101820', $page->getContent());
|
||||
$policy = (string) $page->headers->get('Content-Security-Policy');
|
||||
self::assertStringContainsString("style-src 'self' 'sha256-", $policy);
|
||||
self::assertStringContainsString("script-src 'none'", $policy);
|
||||
|
||||
// Eine Farbe, die weder Hex noch Palettenschlüssel ist, wird nicht gespeichert.
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->site->save(['homePath' => 'home', 'consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => 'datenschutz', 'consentColor' => 'red']);
|
||||
}
|
||||
|
||||
public function testTheQuestionAppearsUntilItIsAnsweredAndCanBeChangedAgain(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Security\UserProvider;
|
||||
@@ -287,7 +288,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 +368,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);
|
||||
@@ -473,6 +475,46 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
|
||||
}
|
||||
|
||||
public function testAFormCarriesItsOwnColoursOntoThePageAndTheAdministrationRefusesBadOnes(): void
|
||||
{
|
||||
$forms = $this->contactForm();
|
||||
$form = $forms->findBySlug('kontakt');
|
||||
// Ohne Gestaltung keine Regel: die Website gilt.
|
||||
self::assertSame('', $forms->css($form, (new Theme($this->db))->load()));
|
||||
$forms->save('kontakt', 'Kontakt', $form['fields'], [...$form['settings'], 'buttonLabel' => 'Anfrage senden', 'fieldBorderColor' => '@ink', 'fieldBackgroundColor' => '#fffdf7', 'buttonColor' => '#124b36'], $form['version']);
|
||||
$this->pageWithForm();
|
||||
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/');
|
||||
self::assertResponseIsSuccessful();
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertSame('kontakt', $crawler->filter('.block-form')->attr('data-form'));
|
||||
self::assertSame('Anfrage senden', $crawler->filter('.block-form button[type="submit"]')->text());
|
||||
// Rahmen und Grund der Felder direkt, der Button ueber die Token des Standardbuttons - und
|
||||
// die Ableitung der Darstellung noch einmal, damit sie diese Fuellung nimmt, nicht die vom
|
||||
// Wurzelelement.
|
||||
preg_match('/\.block-form\[data-form="kontakt"\]\{([^}]*)\}/', $html, $rule);
|
||||
self::assertStringContainsString('--form-border:var(--k-ink)', $rule[1] ?? '');
|
||||
self::assertStringContainsString('--form-bg:#fffdf7', $rule[1] ?? '');
|
||||
self::assertStringContainsString('--kb-fill:#124b36', $rule[1] ?? '');
|
||||
self::assertStringContainsString('--kb-bg:var(--kb-fill)', $rule[1] ?? '');
|
||||
self::assertStringNotContainsString('--kb-ink:', $rule[1] ?? '', 'Nur gesetzte Farben werden geschrieben.');
|
||||
// Die Regel steht im gehashten Stylesblock, den die Richtlinie erlaubt.
|
||||
preg_match('#<style>(.*?)</style>#s', $html, $style);
|
||||
self::assertStringContainsString("'sha256-".base64_encode(hash('sha256', $style[1], true))."'", (string) $client->getResponse()->headers->get('Content-Security-Policy'));
|
||||
|
||||
// Eine Farbe, die keine ist, wird mit Meldung abgelehnt und nichts geaendert.
|
||||
$crawler = $client->request('GET', '/admin/forms');
|
||||
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
|
||||
self::assertCount(5, $crawler->filter('.form-def')->first()->filter('.field-card:not(.field-card-new)'), 'Jedes Feld ist eine Karte.');
|
||||
self::assertSame('Anfrage senden', $crawler->filter('.form-def')->first()->filter('[data-preview-button]')->text());
|
||||
$client->request('POST', '/admin/forms/kontakt', ['_csrf_token' => $token, 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 2, 'buttonColor' => 'rot',
|
||||
'fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]]);
|
||||
$client->followRedirect();
|
||||
self::assertSelectorTextContains('.notice.error', 'kein Farbwert für den Button');
|
||||
self::assertSame('#124b36', $forms->findBySlug('kontakt')['settings']['buttonColor']);
|
||||
}
|
||||
|
||||
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
@@ -769,9 +811,13 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$crawler = $client->request('GET', '/admin/statistik');
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('2', $crawler->filter('.card-head .count')->first()->text());
|
||||
// Both charts - days and hours - are drawn by the server, so they are in the markup and
|
||||
// need no script.
|
||||
self::assertCount(2, $crawler->filter('svg.chart[role="img"]'));
|
||||
// All three charts - days, hours and weekdays - are drawn by the server, so they are in
|
||||
// the markup and need no script.
|
||||
self::assertCount(3, $crawler->filter('svg.chart[role="img"]'));
|
||||
// Jeder Balken sagt beim Ueberfahren, wofuer er steht - auch das ohne Skript.
|
||||
self::assertStringContainsString('2 Aufrufe', $crawler->filter('svg.chart-days rect title')->last()->text());
|
||||
self::assertSame('2', trim($crawler->filter('.stat-tile-main .stat-value')->text()), 'Die Hauptkachel traegt die Summe.');
|
||||
self::assertSame('1', trim($crawler->filter('.stat-tile')->last()->filter('.stat-value')->text()), 'Die letzte Kachel zaehlt die nicht gefundene Adresse.');
|
||||
self::assertCount(0, $crawler->filter('svg [style]'), 'Inline styles are forbidden by the policy.');
|
||||
self::assertStringContainsString('/gibt-es-nicht', $crawler->filter('.stats-list')->last()->text());
|
||||
// And it says what it cannot answer.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\StyleCompiler;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ImageAlignmentTest extends TestCase
|
||||
{
|
||||
public function testAlignmentOnlyAddsRulesToImagesWithAnExplicitSetting(): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
self::assertSame('#plain{width:50%}#text{align-self:center}', $compiler->compileDocument(['blocks' => [
|
||||
['id' => 'plain', 'type' => 'image', 'style' => ['desktop' => ['width' => '50%']]],
|
||||
['id' => 'text', 'type' => 'text', 'style' => ['desktop' => ['alignSelf' => 'center']]],
|
||||
]]));
|
||||
self::assertSame('', $compiler->compileDocument(['blocks' => [
|
||||
['id' => 'bad{selector}', 'type' => 'image', 'style' => ['desktop' => ['alignSelf' => 'center']]],
|
||||
]]));
|
||||
}
|
||||
|
||||
public function testResponsiveResetRestoresAuthoredMarginsAndClearsInnerAlignment(): void
|
||||
{
|
||||
foreach (['auto', 'stretch', 'baseline'] as $reset) {
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'logo', 'type' => 'image', 'style' => [
|
||||
'desktop' => ['width' => '50%', 'alignSelf' => 'center', 'margin' => ['left' => '11px', 'right' => '7px', 'top' => '20px']],
|
||||
'tablet' => ['alignSelf' => 'flex-end'],
|
||||
'mobile' => ['alignSelf' => $reset, 'margin' => ['left' => '15px']],
|
||||
],
|
||||
]]]);
|
||||
self::assertStringContainsString('#logo{margin-left:auto;margin-right:auto;justify-self:center}#logo img{margin-left:auto;margin-right:auto}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#logo{margin-left:auto;margin-right:0;justify-self:end}#logo img{margin-left:auto;margin-right:0}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#logo{margin-left:15px;margin-right:7px;justify-self:auto}#logo img{margin-left:0;margin-right:0}}', $css);
|
||||
self::assertSame(1, substr_count($css, 'margin-top:20px'));
|
||||
}
|
||||
}
|
||||
|
||||
public function testChangingParentFlexAxisRecomputesInheritedImageAlignment(): void
|
||||
{
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'group', 'type' => 'column', 'style' => [
|
||||
'desktop' => ['display' => 'inline-flex', 'flexDirection' => 'row-reverse'],
|
||||
'tablet' => ['flexDirection' => 'column-reverse'],
|
||||
'mobile' => ['flexDirection' => 'row'],
|
||||
], 'children' => [[
|
||||
'id' => 'logo', 'type' => 'image', 'style' => ['desktop' => ['alignSelf' => 'center', 'margin' => ['left' => '9px']]],
|
||||
]],
|
||||
]]]);
|
||||
self::assertStringContainsString('#logo{align-self:center;margin-left:9px}', $css);
|
||||
self::assertStringContainsString('#logo{margin-left:9px;margin-right:0;justify-self:auto}#logo img{margin-left:auto;margin-right:auto}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#logo{margin-left:auto;margin-right:auto;justify-self:center}#logo img{margin-left:auto;margin-right:auto}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#logo{margin-left:9px;margin-right:0;justify-self:auto}#logo img{margin-left:auto;margin-right:auto}}', $css);
|
||||
}
|
||||
|
||||
public function testSectionAccordionAndSliderInnerWrappersUseBlockAlignment(): void
|
||||
{
|
||||
foreach (['section', 'accordionitem', 'slider'] as $type) {
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'group', 'type' => $type, 'style' => ['desktop' => ['display' => 'flex']],
|
||||
'children' => [['id' => 'logo', 'type' => 'image', 'style' => ['desktop' => ['alignSelf' => 'center']]]],
|
||||
]]]);
|
||||
self::assertStringContainsString('#logo{margin-left:auto;margin-right:auto;justify-self:center}#logo img{margin-left:auto;margin-right:auto}', $css);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Kernel;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class KernelCacheTest extends TestCase
|
||||
{
|
||||
public function testAnUpdateKeepsTheRunningCacheAndSelectsANewOneForTheNextRequest(): void
|
||||
{
|
||||
$root = sys_get_temp_dir().'/kant-cache-'.bin2hex(random_bytes(6));
|
||||
mkdir($root);
|
||||
$kernel = static fn (): Kernel => new class($root) extends Kernel {
|
||||
public function __construct(private string $root)
|
||||
{
|
||||
parent::__construct('prod', false);
|
||||
}
|
||||
|
||||
public function getProjectDir(): string
|
||||
{
|
||||
return $this->root;
|
||||
}
|
||||
};
|
||||
try {
|
||||
file_put_contents($root.'/VERSION', "1.10.1\n");
|
||||
$running = $kernel();
|
||||
$old = $running->getCacheDir();
|
||||
self::assertSame($old, $kernel()->getCacheDir());
|
||||
file_put_contents($root.'/VERSION', "1.10.2\n");
|
||||
self::assertSame($old, $running->getCacheDir(), 'The active request retains its cache even after VERSION changes.');
|
||||
self::assertNotSame($old, $kernel()->getCacheDir(), 'A new kernel must not reuse the previous compiled container or Twig templates.');
|
||||
} finally {
|
||||
unlink($root.'/VERSION');
|
||||
rmdir($root);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Domain\InvalidDocument;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ResponsiveVisibilityTest extends TestCase
|
||||
{
|
||||
private function document(int $version = 22): array
|
||||
{
|
||||
return ['schemaVersion' => $version, 'title' => 'Geräte', 'description' => 'Geräteprüfung', 'blocks' => [
|
||||
['id' => 'heading', 'type' => 'heading', 'props' => ['text' => 'Geräte', 'level' => 1]],
|
||||
['id' => 'menu', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal']],
|
||||
]];
|
||||
}
|
||||
|
||||
public function testOldDocumentsAndEveryVisibilityAndMenuOptionRemainValid(): void
|
||||
{
|
||||
$validator = new DocumentValidator();
|
||||
$validator->validate($this->document(21));
|
||||
foreach (['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'] as $visibility) {
|
||||
foreach (['tablet', 'mobile', 'never'] as $collapse) {
|
||||
$document = $this->document();
|
||||
$document['blocks'][1]['layout'] = ['visibility' => $visibility];
|
||||
$document['blocks'][1]['props']['collapse'] = $collapse;
|
||||
$validator->validate($document);
|
||||
self::assertSame($document, json_decode(json_encode($document, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testInvalidValuesAndOlderSchemasRejectNewOptions(): void
|
||||
{
|
||||
foreach ([['visibility', 'desktop', 21], ['collapse', 'tablet', 21], ['visibility', 'injected class', 22], ['collapse', 'sometimes', 22], ['visibility', [], 22], ['collapse', null, 22]] as [$key, $value, $version]) {
|
||||
$document = $this->document($version);
|
||||
$document['blocks'][1]['visibility' === $key ? 'layout' : 'props'][$key] = $value;
|
||||
try {
|
||||
(new DocumentValidator())->validate($document);
|
||||
self::fail('Ungültige Geräteoption wurde akzeptiert.');
|
||||
} catch (InvalidDocument $error) {
|
||||
self::assertNotEmpty($error->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,40 @@ final class StatisticsTest extends TestCase
|
||||
// The hours are summed over the whole range, so both days contribute to 14:00.
|
||||
self::assertSame(5, $overview['hours'][14]['count']);
|
||||
self::assertCount(24, $overview['hours']);
|
||||
|
||||
// Die Summen, die eine Zahl einordnen: nichts davor, der staerkste Tag ist heute, eine
|
||||
// Adresse lief ins Leere, vier Aufrufe kamen ueber einen Verweis, zwei Seiten wurden gesehen.
|
||||
self::assertSame(0, $overview['previousTotal']);
|
||||
self::assertSame('2026-09-12', $overview['busiestDay']);
|
||||
self::assertSame(1, $overview['missingTotal']);
|
||||
self::assertSame(4, $overview['referred']);
|
||||
self::assertSame(2, $overview['pageCount']);
|
||||
// Der 12.09.2026 ist ein Samstag, drei Tage davor ein Mittwoch; jeder Wochentag kommt in
|
||||
// sieben Tagen genau einmal vor, der Schnitt ist also die Summe.
|
||||
self::assertCount(7, $overview['weekdays']);
|
||||
self::assertSame(['weekday' => 6, 'count' => 4, 'average' => 4.0], $overview['weekdays'][5]);
|
||||
self::assertSame(['weekday' => 3, 'count' => 1, 'average' => 1.0], $overview['weekdays'][2]);
|
||||
self::assertSame(0.0, $overview['weekdays'][0]['average']);
|
||||
}
|
||||
|
||||
public function testTheOverviewComparesWithTheSameLengthOfTimeBefore(): void
|
||||
{
|
||||
// Three views eight to ten days ago lie in the seven days before a seven-day range.
|
||||
foreach ([8, 9, 10] as $daysAgo) {
|
||||
$this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify(sprintf('-%d days', $daysAgo)));
|
||||
}
|
||||
// And one 14 days ago is already outside that comparison window.
|
||||
$this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify('-14 days'));
|
||||
$this->stats->record('/', 200, '', 'kant-it.de', $this->now);
|
||||
|
||||
$overview = $this->stats->overview(7, $this->now);
|
||||
self::assertSame(1, $overview['total']);
|
||||
self::assertSame(3, $overview['previousTotal']);
|
||||
// A 404 in the period before does not count as a view there either.
|
||||
$this->stats->record('/weg', 404, '', 'kant-it.de', $this->now->modify('-9 days'));
|
||||
self::assertSame(3, $this->stats->overview(7, $this->now)['previousTotal']);
|
||||
// Without a single view there is no busiest day to point at.
|
||||
self::assertNull($this->stats->overview(3, $this->now->modify('-30 days'))['busiestDay']);
|
||||
}
|
||||
|
||||
public function testOlderDaysAreCompactedAndTheOldestAreRemoved(): void
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EditorHistory, setVisibility, locate, duplicateBlock } from '../public/assets/editor-model.js';
|
||||
|
||||
test('visibility preserves responsive layout and migrates, undoes, redoes and resets atomically', () => {
|
||||
const original = { schemaVersion: 21, title: 'Geräte', description: '', blocks: [{ id: 'section', type: 'section', layout: { width: 'full' }, style: { desktop: { display: 'flex' }, mobile: { display: 'grid' } }, children: [] }] };
|
||||
const history = new EditorHistory(original);
|
||||
history.change(doc => setVisibility(doc, 'section', 'mobile'));
|
||||
assert.equal(history.document.schemaVersion, 22);
|
||||
assert.equal(locate(history.document.blocks, 'section').block.layout.visibility, 'mobile');
|
||||
history.undo(); assert.deepEqual(history.document, original);
|
||||
history.redo(); assert.equal(history.document.blocks[0].layout.visibility, 'mobile');
|
||||
const duplicate = history.change(doc => duplicateBlock(doc, 'section'));
|
||||
assert.equal(locate(history.document.blocks, duplicate).block.layout.visibility, 'mobile');
|
||||
history.change(doc => setVisibility(doc, 'section', 'all'));
|
||||
assert.deepEqual(history.document.blocks[0], original.blocks[0]);
|
||||
assert.throws(() => setVisibility(history.document, 'section', 'invalid'));
|
||||
});
|
||||
Reference in new issue
Block a user