Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dc30a43fe | ||
|
|
9095aa5d2c | ||
|
|
769b0fae2d | ||
|
|
9c8462d46d | ||
|
|
3785d3dfef | ||
|
|
b634f486e5 | ||
|
|
2299184401 |
No files matched your search
@@ -4,6 +4,20 @@ 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`.
|
||||
|
||||
## 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 bleiben die Punkte in der Textfarbe der Sektion und die Knöpfe weiß mit dunkler Schrift. 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 +255,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`.
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 092 – Der Kopfbereich kennt einen gescrollten Zustand (14.09.2026)
|
||||
|
||||
Der transparente Header aus Flatsome ist zwei Dinge: ein Kopf, der über der ersten Sektion liegt, und Farben, die sich beim Scrollen ändern. Beides bleibt getrennt. Das Schweben ist eine Website-Einstellung (`headerOverlay` → `.site-header.is-overlay`, absolut, mit „oben halten“ fixiert), weil es das Layout jeder Seite betrifft. Die Farben sind Gestaltung des Kopfbereichs: neben `hover` gibt es je Gerät einen Block `scrolled`, beschränkt auf Farben, Schatten und Deckkraft (`StyleSchema::scrollable()`) – ein Kopf, der beim Scrollen wächst, würde die Seite unter dem Finger verschieben. Der Compiler gibt `html.is-scrolled #id{…}` plus einen Übergang aus; ein Skript von zehn Zeilen setzt die Klasse ab 40 px und wird nur geladen, wenn der Kopfbereich solche Werte hat, damit Seiten ohne sie ihre strenge Skriptrichtlinie behalten. Ohne Skript gilt der normale Zustand, eine lesbare Seite. Das Design-Panel zeigt die Zeilen „beim Scrollen“ nur im Kopfbereich, weil sie nur dort etwas bewirken; die Editor-Vorschau setzt die Klasse beim Scrollen selbst, da sie kein Skript ausführt. Kein Schemawechsel: `scrolled` ist ein optionaler Schlüssel, alte Dokumente bleiben gültig.
|
||||
|
||||
**Kartenhöhen.** Die Stapelabstände `.block-iconcard + .block-iconcard` (und Zitat/CTA) galten auch im Raster einer Zeile; dort liegt der Rand im Rasterfeld und macht jede Karte außer der ersten um 18 px niedriger. Die Abstände bleiben für Spalten, in `.block-row` werden sie auf null gesetzt – das Raster hat sein eigenes `gap`.
|
||||
|
||||
## ADR 091 – Eine Seitenleiste wie im Flatsome UX Builder; Bewegung spielt, bis die Website etwas anderes sagt (14.09.2026)
|
||||
|
||||
**Editor.** Drei Panels nebeneinander – Werkzeugkasten und Struktur links, Vorschau, Eigenschaften rechts – zeigten immer alles und damit nichts; Umut fand sich nicht zurecht, im Flatsome UX Builder schon. Der Editor übernimmt dessen Rhythmus: eine Seitenleiste, die genau eine Sache zeigt. Reiter für Elemente, Struktur und Seite; ein gewähltes Element öffnet seine eigene Ansicht mit Name, Pfad, den drei Eigenschaftenreitern und den Aktionen am Fuß und gibt sie beim Abwählen wieder frei. Die Reiter bleiben dabei erreichbar, damit Bibliothek und Baum einen Klick entfernt sind. Die Struktur wählt beim Klick nur aus und öffnet erst über „Bearbeiten“; sonst verschwände der Baum bei jedem Schritt. Rechtsklick in der Vorschau öffnet ein Kontextmenü im Editor-Dokument, dessen Position einmal aus Rahmen und Maßstab umgerechnet wird. Tastenkürzel gelten auch, wenn der Fokus in der Vorschau liegt: derselbe Handler hängt am Fenster und an jedem Vorschau-Dokument. Die Auswahl scrollt nur das Vorschau-Fenster, nicht per `scrollIntoView` – das zog das Editor-Dokument mit und schob die Kopfzeile aus dem Bild. Die verstellbaren Panelbreiten entfallen; eine feste Leiste ist der einfachere Vertrag. Bestehende IDs (`#library`, `#structure`, `#properties`, `#preview`, `#save-status` …) bleiben, die Browserprüfung wechselt die Ansicht über Helfer statt über versteckte Elemente zu klicken.
|
||||
|
||||
+57
-1
@@ -1,5 +1,59 @@
|
||||
# Projektstatus
|
||||
|
||||
## 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 siehe Veröffentlichung.
|
||||
|
||||
## 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 +62,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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -507,7 +507,11 @@ function prune(block) {
|
||||
* Sets one style value. An empty value removes it, which makes the block fall back to the wider
|
||||
* device and finally to the theme. Called inside a history transaction like every other change.
|
||||
*/
|
||||
export function setStyle(document, id, device, key, value, { side = null, hover = false } = {}) {
|
||||
export function scrolledStyle(block, device, key) {
|
||||
return block?.style?.[device]?.scrolled?.[key];
|
||||
}
|
||||
|
||||
export function setStyle(document, id, device, key, value, { side = null, hover = false, scrolled = false } = {}) {
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
@@ -519,6 +523,8 @@ export function setStyle(document, id, device, key, value, { side = null, hover
|
||||
block.style[device] ??= {};
|
||||
let target = block.style[device];
|
||||
if (hover) { target.hover ??= {}; target = target.hover; }
|
||||
// The scrolled state of the header: same shape as hover, applied once the page has moved.
|
||||
if (scrolled) { target.scrolled ??= {}; target = target.scrolled; }
|
||||
|
||||
if (side !== null) {
|
||||
if (empty) { if (target[key]) delete target[key][side]; }
|
||||
@@ -559,8 +565,9 @@ 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);
|
||||
|
||||
@@ -877,6 +877,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 +952,7 @@ async function loadDesignSchema() {
|
||||
onDevice: setViewport,
|
||||
getSelection: () => selected,
|
||||
getHistory: () => history,
|
||||
getAreaKind: () => root.dataset.kind,
|
||||
});
|
||||
} catch {
|
||||
// The editor stays usable without it; the panel says so.
|
||||
|
||||
@@ -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();
|
||||
+12
-4
@@ -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,9 +201,9 @@ 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}
|
||||
.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 color-mix(in srgb,var(--slider-ink,#193c2d) 35%,transparent);border-radius:7px;background:var(--slider-bg,#fff);color:var(--slider-ink,#193c2d);cursor:pointer;font:inherit}.slider-controls button:focus-visible{outline:3px solid #b55b12;outline-offset:3px}.slider-controls button:hover{background:color-mix(in srgb,var(--slider-bg,#fff) 85%,var(--slider-ink,#193c2d))}.slider-controls button:disabled{opacity:.6;cursor:default}.slider-position{font-variant-numeric:tabular-nums;min-width:44px;text-align:center;color:var(--slider-ink,inherit)}.slider-control-dots{display:flex;flex-wrap:wrap;gap:3px}.slider-control-dots button{min-width:32px;padding:0;border:0;background:transparent;color:var(--slider-dot,var(--slider-ink,inherit));font-size:0}.slider-control-dots button::before{content:'';width:10px;height:10px;border-radius:50%;border:2px solid currentColor;background:transparent;transition:background-color .2s}.slider-control-dots button[aria-pressed=true]::before{background:currentColor}.slider-control-dots button:hover::before{background:color-mix(in srgb,currentColor 50%,transparent)}.slider-controls[hidden]{display:none}
|
||||
@media(max-width:650px){.slider-controls button{min-height:44px;min-width:44px}}
|
||||
|
||||
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
|
||||
|
||||
+18
-2
@@ -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());
|
||||
@@ -82,7 +98,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(); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
|
||||
});
|
||||
return () => cleanups.forEach(cleanup => cleanup());
|
||||
}
|
||||
|
||||
+15
-15
@@ -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));
|
||||
|
||||
@@ -29,6 +29,10 @@ final class SiteSettings
|
||||
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
||||
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
|
||||
'stickyHeader' => false,
|
||||
// Der Kopfbereich liegt über dem Inhalt statt darüber zu stehen - der Flatsome-Aufbau mit
|
||||
// transparentem Kopf über dem Hero. Welche Farben er beim Scrollen bekommt, bestimmt die
|
||||
// Gestaltung des Kopfbereichs selbst („beim Scrollen" unter Hintergrund).
|
||||
'headerOverlay' => false,
|
||||
// Einblendungen und Hover-Bewegungen. „always" spielt sie wie Flatsome bei jedem
|
||||
// Besucher ab; „respect" hält sich an die Browservorgabe „Bewegung reduzieren", die
|
||||
// manche Systeme melden, ohne dass jemand sie bewusst eingestellt hat.
|
||||
@@ -135,6 +139,7 @@ final class SiteSettings
|
||||
}
|
||||
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
|
||||
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
||||
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
|
||||
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
|
||||
if ('' !== $favicon) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,11 @@ 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'],
|
||||
'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 +224,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'];
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -165,6 +165,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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user