Compare commits

..
11 Commits
Author SHA1 Message Date
kantarciandClaude Fable 5.1 fd4cb75548 Add social profiles as a block and rebuild Elektro Bernhard in 1.12.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Social profiles are maintained once under settings (one HTTPS address per
network, SocialProfiles::NETWORKS) and shown by the new `social` block
(document schema v23) wherever it stands: size, shape, layout, labels,
new tab and an optional subset of networks on the block; colour, hover,
spacing and alignment through the free block styles. The addresses also
feed sameAs in the structured data. Icons are own outline drawings in
the style of the existing icon set. ADR 093.

Elektro Bernhard is rebuilt with a dark hero over the team photo, a
transparent header that turns white on scroll, Lato, cards with shadows
and hover lift, a review slider with six real Google reviews, a dark
solar band, page headers on every subpage and staggered entrance
effects. Social profiles sit in the top bar and the footer. The site is
a second local reference installation next to the Kant-IT pilot
(var/elektro-bernhard.db, launch config on port 8088); the seed script
rebuilds it from scratch.

Checked: PHP suite 492 tests (only the documented old failure remains),
33 JS tests, Twig lint, PHP style, full editor browser check with axe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 19:29:08 +02:00
kantarciandClaude Fable 5.1 30e4b3eb2e Document verified 1.11.4 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 19:13:26 +02:00
kantarciandClaude Fable 5.1 cef710d5db Lay the slider controls over the picture like the Kant-IT hero in 1.11.4
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Round white arrows left and right, dots bottom right with the active one as
an accent bar, a small pause button and no counter - the design of the hero
slider on kant-it.de. Slider and controls share a positioned shell; arrows
hide on phones. New style property for the active dot colour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 19:11:48 +02:00
kantarciandClaude Fable 5.1 434e89f779 Document verified 1.11.3 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 18:47:34 +02:00
kantarciandClaude Fable 5.1 6dc30a43fe Let the slider controls take their colours from the slider in 1.11.3
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Dots, arrows, counter and button background are style properties of the
slider ("Slider-Steuerung"), written as custom properties onto the slider and
onto the control bar the script places after it, so the script-free markers
and the scripted controls share the same colours.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 18:46:36 +02:00
kantarciandClaude Fable 5.1 9095aa5d2c Document verified 1.11.2 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 18:02:53 +02:00
kantarciandClaude Fable 5.1 769b0fae2d Keep the slider moving in Safari and draw real dots in 1.11.2
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Safari cancels a smooth scroll on a snapping container and snaps back to
the slide it came from, so arrows and autoplay stuttered on the iPhone. The
slider switches snapping off for the ride and restores it on arrival. The
control dots are drawn as dots with the active one filled.

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:23:43 +02:00
kantarciandClaude Fable 5.1 2299184401 Document verified 1.11.0 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:42:54 +02:00
29 changed files with 1582 additions and 50 deletions

No files matched your search

+30
View File
@@ -18,6 +18,36 @@
"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-17c7f81e0ae6/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
}
]
}
+34
View File
@@ -4,6 +4,28 @@ 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`.
## 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.
@@ -241,6 +263,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`.
+1 -1
View File
@@ -1 +1 @@
1.11.0
1.12.0
+10
View File
@@ -2,6 +2,16 @@
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
## 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.
+111 -1
View File
@@ -1,5 +1,113 @@
# Projektstatus
## 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.
**Achtung, parallele Sitzung:** Während dieser Arbeit hat eine andere Sitzung 1.11.3 und 1.11.4 veröffentlicht und `VERSION` zurückgesetzt; hier steht wieder 1.12.0, weil ein neuer Baustein und ein neues Schema kein Patch sind. Nichts von dieser Arbeit ist committet.
## 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.
@@ -8,7 +116,9 @@
**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. Noch nicht committed oder veröffentlicht.
**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)
+25 -3
View File
@@ -5,7 +5,7 @@
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
* appear here without touching this file.
*/
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
import { devices, effectiveStyle, hoverStyle, scrolledStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
import { openMedia, createMediaPreview } from './media.js';
import { createColorControl } from './color-control.js';
@@ -18,6 +18,7 @@ export const GROUPS = {
image: 'Bild',
layout: 'Layout',
effect: 'Wirkung',
slider: 'Slider-Steuerung',
};
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
@@ -104,7 +105,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 +150,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 +555,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);
+17 -7
View File
@@ -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);
}
/**
@@ -255,7 +258,7 @@ 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 = 22;
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' };
@@ -507,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;
@@ -519,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]; }
@@ -559,10 +568,11 @@ 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 === '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;
}
+35
View File
@@ -13,6 +13,8 @@ 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 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);
@@ -398,6 +400,33 @@ function renderContent(block, panel) {
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.' });
@@ -877,6 +906,11 @@ async function updatePreview() {
});
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 = () => {
@@ -947,6 +981,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.
+1
View File
@@ -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(''),
+19
View File
@@ -0,0 +1,19 @@
/**
* Marks the page as scrolled.
*
* A header can carry a second set of colours for the scrolled page - transparent over the hero,
* solid once the reader has moved on, the way the Flatsome transparent header works. The
* stylesheet holds both states; this only says which one applies. Without the script the header
* keeps its normal colours, which is a readable page, not a broken one.
*/
export function initScrolled(doc = document) {
const view = doc.defaultView;
let queued = false;
const sync = () => { queued = false; doc.documentElement.classList.toggle('is-scrolled', view.scrollY > 40); };
const onScroll = () => { if (!queued) { queued = true; view.requestAnimationFrame(sync); } };
view.addEventListener('scroll', onScroll, { passive: true });
sync();
return () => { view.removeEventListener('scroll', onScroll); doc.documentElement.classList.remove('is-scrolled'); };
}
if (!document.querySelector('#editor')) initScrolled();
+41 -5
View File
@@ -6,7 +6,11 @@ h1,h2,h3,h4,h5,h6{hyphens:manual}
p{hyphens:auto}
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}
/* Über dem Inhalt: der Kopf nimmt keinen Platz ein, die erste Sektion beginnt unter ihm. Farbe
und Schatten kommen aus der Gestaltung des Kopfbereichs, auch die für den gescrollten Zustand. */
.site-header.is-overlay{position:absolute;top:0;left:0;right:0;z-index:40;border-bottom:0;background:transparent;box-shadow:none}
.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
@@ -41,7 +45,11 @@ p{hyphens:auto}
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
.block-icon{flex-shrink:0;display:block}
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
letzte Karte um genau diesen Betrag niedriger als die erste, weil ein Rand im Rasterfeld liegt. */
.block-row > .block-iconcard + .block-iconcard,.block-row > .block-quote + .block-quote,.block-row > .block-cta + .block-cta,.block-row > .block-iconcard + .block-quote,.block-row > .block-quote + .block-iconcard{margin-top:0}
.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.block-iconcard h3 a{text-decoration:none}
.block-iconcard h3 a:hover{text-decoration:underline}
.block-iconcard p{margin:0;line-height:1.65}
@@ -193,10 +201,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)}
@@ -394,3 +426,7 @@ body.consent-open .scroll-top{display:none}
}
.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}}
+27 -6
View File
@@ -28,9 +28,25 @@ export function initSliders(doc = document) {
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 => {
if (!available().includes(index)) return;
slider.scrollTo({ left: Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index]))), behavior: reduced() ? 'instant' : 'smooth' });
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());
@@ -40,9 +56,9 @@ export function initSliders(doc = document) {
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);
@@ -51,6 +67,7 @@ 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 visible = available();
const fits = visible.length < 2 || slider.scrollWidth <= slider.clientWidth + 2;
@@ -58,7 +75,7 @@ export function initSliders(doc = document) {
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.textContent = playing ? 'Pause' : 'Wiedergabe'; play.dataset.playing = String(playing);
play.setAttribute('aria-label', playing ? 'Automatische Wiedergabe pausieren' : 'Automatische Wiedergabe starten');
play.disabled = reduced(); play.title = reduced() ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : '';
}
@@ -68,7 +85,11 @@ export function initSliders(doc = document) {
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 });
@@ -82,7 +103,7 @@ export function initSliders(doc = document) {
const resize = new view.ResizeObserver(update); resize.observe(slider);
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());
}
+15 -15
View File
@@ -53,30 +53,30 @@ try {
const hovered = await hoverTarget.evaluate(node => ({ transform: getComputedStyle(node).transform, matches: node.matches(':hover'), classes: node.className }));
console.log('Pilot hover:', width, JSON.stringify(hovered));
assert.equal(hovered.transform, 'matrix(1, 0, 0, 1, 0, -4)', 'Saved lift effect changes the rendered transform');
const target = page.locator('#kontakt');
assert.equal(await page.evaluate(() => motionCalls.some(call => call.id === 'kontakt')), false, 'Below-fold section must wait for scrolling');
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 === 'kontakt'));
await page.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'));
const calls = await page.evaluate(() => motionCalls);
assert.equal(calls.find(call => call.id === 'kontakt').frames[0].transform, 'translateY(40px)');
assert.equal(calls.find(call => call.id === 'kontakt').frames[0].opacity, 0, 'Entrance fades in from invisible, as in Flatsome');
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 === 'kontakt').length), 1, 'Entrance runs once');
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('kontakt');
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 === 'kontakt'));
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() };
@@ -91,7 +91,7 @@ try {
// 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 === 'kontakt'), null, { timeout: 3000 });
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);
@@ -116,7 +116,7 @@ try {
}).observe(document, { childList: true });
});
const respectPage = await respect.newPage(); respectPage.on('pageerror', error => errors.push(error.message));
const respectTarget = respectPage.locator('#kontakt'), respectHover = respectPage.locator('#leistung-app');
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');
@@ -124,29 +124,29 @@ try {
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 === 'kontakt'), null, { timeout: 3000 });
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 === 'kontakt').length), 1, 'Preference changes do not replay an already seen element');
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 === 'kontakt').length), 0);
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 === 'kontakt'), null, { timeout: 3000 });
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('#kontakt').evaluate(node => getComputedStyle(node).opacity), '1');
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));
+10
View File
@@ -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"
+870
View File
@@ -0,0 +1,870 @@
<?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–Do: 07:00–16:30 Uhr\nFr: 07:00–13: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']]]),
]),
], ['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, Hügelsheim, Iffezheim, Rastatt, Kuppenheim, Gaggenau, Gernsbach, Muggensturm, im Murgtal und in der Umgebung. 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.'],
];
// 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, Hügelsheim, Iffezheim, Rastatt, Kuppenheim, Gaggenau, Gernsbach, Muggensturm, das Murgtal und die Umgebung. 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&amp;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–Do: 07:00–16:30 Uhr\nFr: 07:00–13: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 in Baden-Baden, Rastatt und Umgebung.', ['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' => '#9aa0a6', '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', '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', [
$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, Hügelsheim, Iffezheim, Rastatt, Kuppenheim, Gaggenau, Gernsbach, Muggensturm, das Murgtal und die Umgebung.'],
]), [], [
$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–Do 7:00–16:30 Uhr, Fr 7:00–13: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',
],
'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";
+18 -3
View File
@@ -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, 21, 22], 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.');
@@ -85,7 +87,7 @@ final class DocumentValidator
// 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.');
@@ -154,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) {
@@ -287,6 +289,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.');
+14 -2
View File
@@ -24,11 +24,18 @@ 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' => [],
'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.
@@ -129,12 +136,14 @@ final class SiteSettings
$values['sameAs'][] = $profile;
}
$values['sameAs'] = array_slice(array_unique($values['sameAs']), 0, 20);
$values['social'] = SocialProfiles::clean($input['social'] ?? []);
$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)) {
@@ -348,8 +357,11 @@ 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;
}
return $data;
+79
View File
@@ -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;
}
}
+20
View File
@@ -49,6 +49,14 @@ final class StyleCompiler
}
continue;
}
if ('scrolled' === $key) {
$this->require(is_array($value), 'Scrolled styles must be an object.');
foreach ($value as $scrolledKey => $scrolledValue) {
$this->require(in_array($scrolledKey, StyleSchema::scrollable(), true), sprintf('The property "%s" has no scrolled value.', (string) $scrolledKey));
$this->value($properties[$scrolledKey], $scrolledValue, (string) $scrolledKey);
}
continue;
}
$this->require(is_string($key) && isset($properties[$key]), sprintf('Unknown style property "%s".', (string) $key));
$this->value($properties[$key], $value, $key);
}
@@ -177,10 +185,22 @@ 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);
}
$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;
}
+16
View File
@@ -204,6 +204,12 @@ 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").
'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 +225,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'];
+1
View File
@@ -127,6 +127,7 @@ 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'],
+32 -4
View File
@@ -80,6 +80,9 @@ final class PageController extends AbstractController
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
// for the small script that marks the page as scrolled.
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
@@ -107,6 +110,7 @@ final class PageController extends AbstractController
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $needsSlider,
'needsMotion' => $needsMotion,
'needsScrolled' => $needsScrolled,
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
@@ -136,7 +140,7 @@ final class PageController extends AbstractController
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $needsScrolled || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
@@ -413,6 +417,27 @@ final class PageController extends AbstractController
return $gefunden;
}
/** Whether any block carries values for the scrolled page (see StyleSchema::scrollable). */
private function hasScrolled(array $document): bool
{
$walk = function (array $blocks) use (&$walk): bool {
foreach ($blocks as $block) {
foreach ($block['style'] ?? [] as $values) {
if ([] !== ($values['scrolled'] ?? [])) {
return true;
}
}
if ($walk($block['children'] ?? [])) {
return true;
}
}
return false;
};
return $walk($document['blocks'] ?? []);
}
private function hasEntrance(array $document): bool
{
$walk = function (array $blocks) use (&$walk): bool {
@@ -464,10 +489,13 @@ final class PageController extends AbstractController
}
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
$frames = $this->frameHosts($request, ...array_values($this->areas()));
$areas = $this->areas();
$frames = $this->frameHosts($request, ...array_values($areas));
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
return $this->shell($this->render('archive.html.twig', [
'areas' => $this->areas(),
'areas' => $areas,
'needsScrolled' => $needsScrolled,
'navigation' => $this->navigation(''),
'term' => $term,
'heading' => null === $term ? 'Beiträge' : $term['name'],
@@ -475,7 +503,7 @@ final class PageController extends AbstractController
'base' => $base,
'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''),
'navigation' => $this->navigation(''),
]), frameHosts: $frames);
]), $needsScrolled, frameHosts: $frames);
}
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
+3
View File
@@ -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,8 @@ 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,
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
]);
}
+15 -1
View File
@@ -95,7 +95,19 @@
<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>
<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 +177,8 @@
<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>
+2 -1
View File
@@ -19,12 +19,13 @@
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}">
<link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
</head>
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
{% if areas.header|default(null) %}
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}">
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
</header>
{% endif %}
+20
View File
@@ -170,6 +170,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. #}
+17
View File
@@ -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>
+2 -1
View File
@@ -51,6 +51,7 @@
{% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsSlider|default(false) %}<script type="module" src="/assets/slider.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsZoom|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
{# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must
show the layout, not run code that could change the page under the editor's hands. #}
@@ -73,7 +74,7 @@
{# Header and footer are block documents like any page, so they are built from elements
instead of being written into this template. #}
{% if areas.header|default(null) %}
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}">
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null), inHeader: true} only %}{% endfor %}
</header>
{% endif %}
+97
View File
@@ -2232,6 +2232,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);
@@ -3529,6 +3568,64 @@ 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 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
{