Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
806df4b5df | ||
|
|
7251b82f09 | ||
|
|
30d8d58bbf | ||
|
|
5a8d67a638 |
No files matched your search
@@ -109,4 +109,4 @@ Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche
|
||||
|
||||
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` gehört danach in einen täglichen Cronjob. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts.
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` gehört danach in einen täglichen Cronjob. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts.
|
||||
@@ -440,6 +440,22 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||
|
||||
## ADR 036 – Der Designer im Vergleich, und zwei Fehler, die er verdeckte (Dokumentschema v15, 12.09.2026)
|
||||
|
||||
Umut fragte nach Zeilen, nach Rahmen und Rundungen – und ob der Designer so aufgestellt ist wie Flatsome. Der Vergleich steht in `docs/DESIGNER-VERGLEICH.md`, samt der Einschränkung, dass ich Flatsome nicht selbst öffnen konnte. Was die Frage an Code auslöste:
|
||||
|
||||
**Zwei Fehler, die vorher niemand gesehen hat.** Eine Rahmenstärke je Seite wurde zu `border-width-top` – das ist kein CSS, der Browser verwirft die Zeile, und niemand bekommt eine Meldung. Rahmenstärken je Seite hatten damit **nie** eine Wirkung. Die Schreibweise steht jetzt als `sideCss` je Eigenschaft im Schema, weil `padding-top` dem Namen folgt und `border-top-width` nicht. Und eine Rundung auf einem Bildblock landete auf dem `figure`, während das Bild darin eckig blieb.
|
||||
|
||||
**Eigenschaften können jetzt auf das Bild im Block zielen.** Ein `target: 'img'` im Schema, und der Compiler schreibt eine zweite Regel `#id img{…}`. Das ist der Grund, warum `object-fit` überhaupt etwas tun kann: auf einem `figure` tut es nichts. Damit kommen Bildausschnitt, Bildmitte, Seitenverhältnis, Bildhöhe, Bildecken und Bildrahmen.
|
||||
|
||||
**Von 50 auf 93 Eigenschaften.** Dazu Positionierung mit z-index (freigestellte Elemente, wie in Flatsome-Bannern), getrennte Zeilen- und Spaltenabstände, Flex-Eigenschaften je Element, Innenschatten, Textschatten, acht Bildfilter als eine zusammengesetzte `filter`-Deklaration in fester Reihenfolge, Mischmodus und Mauszeiger. Neun Einblendungen mit Verzögerung, sieben Hover-Effekte – alle innerhalb von `prefers-reduced-motion`.
|
||||
|
||||
**Drei neue Bausteine (v15).** Symbol als eigenes Element; Fortschritt mit `role="meter"` und dem Wert im Markup statt nur in der Balkenbreite; und ein **Slider ohne JavaScript** – ein Scrollbereich mit Einrasten, sichtbare Folien je Gerät, ohne Autoplay. Kein Autoplay ist eine Entscheidung: ein sich selbst bewegendes Karussell ist etwas, das Menschen anhalten wollen.
|
||||
|
||||
Weil Inline-Stile verboten sind, sind Symbolgröße und Fortschrittswert **Klassen**. Beim Symbol heißt das: nur die Größen aus einer festen Liste sind erlaubt – ein freier Wert hätte keine Klasse, und ein SVG ohne Maße ist unsichtbar. Der Validator kennt die Liste, der Editor bietet genau sie an.
|
||||
|
||||
**Zeilen waren nicht unauffindbar, sondern unbrauchbar leer.** „Zeile“ stand immer in der Elementliste, aber eine Zeile ohne Spalten sieht aus wie nichts. Jetzt gibt es „Zeile, 2/3/4 Spalten“ auf einen Klick und im Werkzeugkasten den Satz, wie der Aufbau gemeint ist: Sektion → Zeile → Spalte → Inhalt.
|
||||
|
||||
## ADR 035 – Der Updater: jeder Schritt prüft, der Rückweg ist Teil des Wegs (12.09.2026)
|
||||
|
||||
Umgesetzt wie in `docs/UPDATE.md` geplant, mit drei Dingen, die in der Umsetzung wichtiger wurden als im Plan.
|
||||
|
||||
@@ -114,6 +114,12 @@ Seiten umbenennen und verschieben, mit Aktualisierung aller Kindpfade in einer T
|
||||
|
||||
Abnahme: Eine Seite mit Unterseiten wird verschoben; alle alten Adressen leiten weiter, Menüs bleiben intakt.
|
||||
|
||||
### G3 – Designer auf Flatsome-Niveau (Nutzerauftrag vom 12.09.2026) — **weitgehend erledigt am 12.09.2026** (ADR 036)
|
||||
|
||||
Umuts Auftrag: „Prüfe es und baue alles wirklich alles genauso ein styles, effekte, und mehr.“ Der Vergleich steht in `docs/DESIGNER-VERGLEICH.md`. Gebaut: 93 statt 50 Eigenschaften, neun Einblendungen mit Verzögerung, sieben Hover-Effekte, Bild-Eigenschaften mit eigenem Ziel, Positionierung, Filter, Symbol, Fortschritt, Slider, Zeilenvorlagen.
|
||||
|
||||
Offen und dort begründet: Sichtbarkeitsschalter je Gerät, Parallax, Video-Hintergrund, selbst gehostetes Video, Countdown, Popup, Einblendung beim Scrollen, soziale Symbole, Preistabelle, Teammitglied, Suche, Vorlagenbibliothek. Die vier mit Skriptbedarf (Countdown, Popup, Scroll-Einblendung, Autoplay) sind eine Entscheidung, keine Lücke – sie weichen `script-src 'none'` auf und werden nur auf Zuruf gebaut.
|
||||
|
||||
### G – Editor für die tägliche Arbeit
|
||||
|
||||
*Braucht: C.*
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Der Designer im Vergleich mit Flatsome (UX Builder)
|
||||
|
||||
Stand: 12.09.2026.
|
||||
|
||||
## Woran dieser Vergleich hängt
|
||||
|
||||
Umut hat gefragt: „Ist der Designer vom Funktionsumfang so aufgestellt wie Flatsome?“ Zur Ehrlichkeit gehört, wie ich das geprüft habe – und wie nicht.
|
||||
|
||||
**Nicht geprüft:** Flatsome selbst. Die Erweiterung ist hier nicht installiert, das WordPress-Backend des Piloten ist mir nicht zugänglich, und in der öffentlich ausgelieferten Startseite sieht man die Bausteine, nicht den Baukasten. Ich habe also **keinen Klick in UX Builder gemacht**.
|
||||
|
||||
**Geprüft:** der eigene Designer, Eigenschaft für Eigenschaft, gegen den mir bekannten Funktionsumfang von UX Builder (Elementliste, Optionsgruppen je Element, Animations- und Hover-Sammlung). Das ist Wissen, keine Messung – wo es falsch ist, korrigiere mich, dann ziehe ich nach. Für die konkrete Pilotseite ist ohnehin der Abgleich aus Arbeitspaket K maßgeblich, und der braucht Backendzugang.
|
||||
|
||||
## Gestaltungsoptionen
|
||||
|
||||
Vor heute hatte der Designer 50 Eigenschaften, jetzt 93 – jede je Gerät (Desktop, Tablet, Mobil) und ein Teil zusätzlich für den Hover-Zustand.
|
||||
|
||||
| Bereich | Flatsome (UX Builder) | KantSites |
|
||||
| --- | --- | --- |
|
||||
| Typografie | Schriftart, Größe, Gewicht, Zeilenhöhe, Laufweite, Ausrichtung, Schreibweise, Farbe | **11 Eigenschaften**, dazu Schriftlage, Textlinie, Wortabstand; Schriftart aus der Website-Palette (Systemschrift oder Google Fonts) |
|
||||
| Hintergrund | Farbe, Bild, Position, Größe, Wiederholung, Parallax, Überlagerung, Verlauf, Video | **11**: Farbe, Bild aus der Mediathek, Größe, Ausschnitt, Wiederholung, Scrollverhalten, Überlagerung mit Deckkraft, Verlauf mit Winkel. **Fehlt: Parallax und Video-Hintergrund** |
|
||||
| Abstand | Innen/Außen je Seite | **je Seite**, je Gerät |
|
||||
| Größe | Breite, Höhe, Mindesthöhe | **7**: Breite, Mindest-, Maximalbreite, Höhe, Mindest-, Maximalhöhe, Überlauf |
|
||||
| Rahmen | Stärke je Seite, Farbe, Art, Rundung je Ecke | **8**: alles davon, dazu ein zweiter Rahmen (outline) mit Abstand. **Heute behoben:** die Stärke je Seite erzeugte `border-width-top` statt `border-top-width` – ungültiges CSS, das der Browser stillschweigend verwarf. Rahmenstärken je Seite hatten damit nie eine Wirkung |
|
||||
| Bild | Rundung, Zuschnitt, Seitenverhältnis, Filter | **neu, 8 Eigenschaften**, die auf das Bild *im* Block wirken statt auf seinen Rahmen: Ausschnitt (object-fit), Bildmitte, Seitenverhältnis, Höhe, Ecken je Ecke, Rahmen. Vorher landete eine Rundung auf dem `figure` und das Bild blieb eckig – genau das, was Umut aufgefallen ist |
|
||||
| Layout | Spalten, Abstand, Verteilung, Ausrichtung | **20**: Anzeige, Flexrichtung, Umbruch, Verteilung, Ausrichtung, Spaltenzahl, Abstände (auch getrennt für Zeilen und Spalten), Reihenfolge, eigene Ausrichtung, Wachsen, Schrumpfen, Grundbreite, **Positionierung (auch freigestellt und klebend), Abstände zu allen vier Rändern, Ebene (z-index)** |
|
||||
| Wirkung | Schatten, Deckkraft, Animationen, Hover | **26**: Deckkraft, Schatten (auch **innen**), Textschatten, Verschieben, Skalieren, Drehen, Übergangsdauer, **Weichzeichnen, Helligkeit, Kontrast, Sättigung, Graustufen, Sepia, Farbdrehung, Umkehren, Mischmodus, Mauszeiger** |
|
||||
| Eigener Code | CSS je Element | **CSS je Element für alle, Skript je Element nur für Administratoren** (ADR 028) |
|
||||
| Sichtbarkeit je Gerät | „Hide on mobile/tablet/desktop“ | über `Anzeige: keine` je Gerät möglich, aber **nicht als eigener Schalter** – siehe „Was noch fehlt“ |
|
||||
|
||||
### Bewegung
|
||||
|
||||
| | Flatsome | KantSites |
|
||||
| --- | --- | --- |
|
||||
| Einblendungen | rund fünfzehn, beim Scrollen ausgelöst | **neun** (sanft, von unten, von oben, von links, von rechts, Heranzoomen, Kippen, aus der Unschärfe) mit **Verzögerung** in drei Stufen. **Sie laufen beim Laden der Seite, nicht beim Scrollen** – dafür bräuchte es JavaScript, und eine öffentliche Seite führt keines aus. Das steht so im Editor |
|
||||
| Hover | rund zehn | **sieben** (anheben, absenken, Bild vergrößern, Leuchten, Kippen, Rahmen einfahren) – dazu **frei**: jede Farbe, jeder Schatten, jede Verschiebung, jede Skalierung hat einen eigenen Hover-Wert |
|
||||
| „Bewegung reduzieren“ | teilweise berücksichtigt | **jede** Animation liegt in der Abfrage `prefers-reduced-motion`; wer weniger Bewegung wünscht, bekommt den Endzustand |
|
||||
|
||||
## Bausteine
|
||||
|
||||
| Flatsome-Element | KantSites |
|
||||
| --- | --- |
|
||||
| Section, Row, Column | Sektion, Zeile, Spalte – **neu: „Zeile, 2/3/4 Spalten“ auf einen Klick**, weil eine leere Zeile aussieht wie nichts und deshalb niemand sie gefunden hat |
|
||||
| Text, Headline | Text mit Werkzeugleiste und HTML-Ansicht, Überschrift |
|
||||
| Button | Button |
|
||||
| Image, Gallery | Bild, Galerie |
|
||||
| Icon, Icon Box | **neu: Symbol** als eigenes Element (vorher nur in der Symbolkarte), Symbolkarte |
|
||||
| Slider, Banner | **neu: Slider** als Scrollbereich mit Einrasten – ohne JavaScript, ohne Autoplay, mit Finger, Trackpad, Mausrad und Tastatur bedienbar. Sichtbare Folien je Gerät einstellbar |
|
||||
| Accordion, Tabs | Akkordeon, Tabs |
|
||||
| Progress bar | **neu: Fortschritt** – mit `role="meter"` und dem Wert im Markup, nicht nur in der Balkenbreite |
|
||||
| Testimonial | Kundenstimme mit Sternen |
|
||||
| Counter, Countdown | **fehlt** – braucht JavaScript |
|
||||
| Team member, Price table | **fehlt als eigenes Element**; aus Zeile, Spalte, Bild, Überschrift und Liste zusammensetzbar |
|
||||
| Video | **teils**: ein eingebettetes Video geht über „Externer Inhalt“ und lädt erst nach Einwilligung. Eine selbst gehostete Videodatei fehlt |
|
||||
| Google Map | über „Externer Inhalt“ – **und zwar richtig**: ohne Einwilligung wird keine Verbindung zu Google aufgebaut |
|
||||
| Social icons, Share | **fehlt als Element**; aus Symbolen mit Linkziel zusammensetzbar |
|
||||
| Contact form | Formular, serverseitig validiert, mit Einwilligung und Spamschutz |
|
||||
| Blog posts | Beitragsliste, nach Kategorie oder Schlagwort filterbar |
|
||||
| Shop-Elemente | **bewusst nicht** – kein Shop im Projekt |
|
||||
| Popup, Lightbox | **fehlt** – braucht JavaScript |
|
||||
| HTML, Custom CSS | eigenes CSS je Element für alle, eigenes Skript nur für Administratoren |
|
||||
| Divider, Gap | Trennlinie, Abstand |
|
||||
| Search | **fehlt** – es gibt keine Suche auf der Website (Arbeitspaket M) |
|
||||
| Breadcrumbs, Navigation | Brotkrumen, Navigation aus der Menüverwaltung |
|
||||
| Verknüpfte Abschnitte | Verknüpfte Sektion – eine Änderung wirkt auf allen Seiten, die sie einbinden |
|
||||
|
||||
## Was absichtlich anders ist
|
||||
|
||||
Das ist kein Funktionsmangel, sondern eine Entscheidung:
|
||||
|
||||
1. **Eine öffentliche Seite führt kein JavaScript aus** (`script-src 'none'`), außer sie enthält Tabs oder ein eigenes Skript. Deshalb gibt es kein Autoplay-Karussell, keinen Countdown und kein Popup. Der Preis: diese drei fehlen. Der Gegenwert: die Seite ist ohne Skript vollständig lesbar und schnell, und eine ganze Klasse von Fehlern und Angriffen entfällt.
|
||||
2. **Keine Inline-Stile.** Jede Gestaltung wird zu einem Stylesheet mit Hash zusammengefasst. Deshalb sind Symbolgrößen und Fortschrittswerte Klassen und keine `style`-Attribute.
|
||||
3. **Eingebettete Inhalte laden erst nach Einwilligung** – vorher steht nicht einmal die Adresse des Anbieters im HTML.
|
||||
4. **Jeder Wert wird serverseitig geprüft.** Was der Editor anbietet, ist genau das, was der Server annimmt; ein Muster oder eine feste Liste je Eigenschaft, keine freie CSS-Eingabe außer im ausdrücklich dafür gedachten Feld.
|
||||
5. **Kein Shop und keine Produktelemente.**
|
||||
|
||||
## Was noch fehlt, nach Nutzen geordnet
|
||||
|
||||
1. **Sichtbarkeit je Gerät als eigener Schalter.** Technisch über `Anzeige: keine` je Gerät schon möglich, aber niemand sucht es dort. Ein Häkchen „Auf diesem Gerät ausblenden“ im Designbereich – klein, hoher Nutzen.
|
||||
2. **Parallax und Video-Hintergrund.** Parallax geht in modernen Browsern rein mit CSS (`background-attachment: fixed` gibt es schon; echtes Parallax bräuchte `animation-timeline: scroll()`, das Safari und Firefox noch nicht können). Video-Hintergrund braucht ein `<video>`-Element und eine Entscheidung über Datenmenge und Einwilligung.
|
||||
3. **Selbst gehostetes Video** als Element, mit Vorschaubild und ohne Autoplay mit Ton.
|
||||
4. **Countdown und Popup.** Beide brauchen ein Skript. Machbar wie die Tabs: eine Seite, die eines enthält, bekommt `script-src 'self'` und lädt eine kleine Datei. Das ist eine bewusste Aufweichung – ich würde sie nur bauen, wenn du sie wirklich brauchst.
|
||||
5. **Einblendung beim Scrollen** statt beim Laden. Ohne Skript nur mit `animation-timeline: view()` möglich, das noch nicht überall funktioniert; mit Skript sofort.
|
||||
6. **Soziale Symbole, Preistabelle, Teammitglied** als eigene Elemente – heute zusammensetzbar, aber jedes Mal Handarbeit.
|
||||
7. **Suche auf der Website.**
|
||||
8. **Vorlagenbibliothek.** Flatsome bringt fertige Seitenvorlagen mit. Hier gibt es drei Abschnittsvorlagen und die Zeilenvorlagen; eine echte Sammlung gehört zu Arbeitspaket H (Templates).
|
||||
|
||||
## Was heute dabei herauskam
|
||||
|
||||
Neben dem Ausbau zwei echte Fehler, beide vorher unbemerkt:
|
||||
|
||||
- **Rahmenstärken je Seite hatten nie eine Wirkung.** `border-width-top` ist kein CSS; der Browser verwirft die Zeile stillschweigend. Die Schreibweise steht jetzt je Eigenschaft im Schema, weil sie sich nicht erraten lässt.
|
||||
- **Eine Rundung auf einem Bildblock rundete den Rahmen, nicht das Bild.** Jetzt erbt das Bild die Rundung seines Rahmens, und die neuen Bild-Eigenschaften schreiben ohnehin direkt auf das Bild.
|
||||
+11
-1
@@ -1,5 +1,14 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Designer ausgebaut, 12.09.2026
|
||||
|
||||
- **315 PHP-Tests / 1309 Assertions** unter PHP 8.2.12 und 8.4.22, 16 JavaScript-Modelltests, Playwright-Editortest (axe auf dreizehn Bildschirmen, null Verstöße), öffentliche Browserprüfung.
|
||||
- Die Gestaltungseigenschaften sind nicht „vorhanden“, sondern einzeln nachgerechnet: 16 Datensätze vergleichen die erzeugte Deklaration Zeichen für Zeichen mit der erwarteten. Das ist die Prüfung, die die beiden Fehler unten gefunden hätte, wenn es sie vorher gegeben hätte.
|
||||
- **Zwei Fehler, die vorher niemand gesehen hat.** Erstens: eine Rahmenstärke je Seite wurde zu `border-width-top`. Das ist kein CSS, der Browser verwirft die Zeile stillschweigend – Rahmenstärken je Seite hatten **nie** eine Wirkung, und es gab keine Fehlermeldung, die darauf hingewiesen hätte. Zweitens: eine Rundung auf einem Bildblock landete auf dem `figure`, das Bild darin blieb eckig. Beides ist genau das, wonach Umut gefragt hat – er hat zwei echte Defekte gefunden, indem er die Funktion benutzen wollte.
|
||||
- Ein Test hält das Schema und das Editorpanel zusammen: jede Gruppe im Schema muss im Panel einen Namen haben, sonst landen ihre Eigenschaften in einem unbenannten Aufklappfeld.
|
||||
- Die drei neuen Bausteine sind über echte Seitenauslieferung geprüft, nicht nur über den Validator: Markup, Rollen, Klassen – und dass die Seite weiterhin `script-src 'none'` sendet, denn keiner von ihnen braucht ein Skript.
|
||||
- **Nicht geprüft:** kein Screenreader. Der Slider ist nicht mit Touch auf einem echten Gerät bedient worden, nur mit Tastatur und Mausrad im Browser. Und der Vergleich mit Flatsome ist Wissen, keine Messung – die Erweiterung war nicht installierbar, das steht so im Vergleichsdokument.
|
||||
|
||||
## Ergänzung: Updater, 12.09.2026
|
||||
|
||||
- **285 PHP-Tests / 1244 Assertions** unter PHP 8.2.12 und 8.4.22, Playwright-Editortest (axe auf dreizehn Bildschirmen, null Verstöße), öffentliche Browserprüfung.
|
||||
@@ -7,7 +16,8 @@
|
||||
- Der wichtigste davon ist der Fehlschlag: die neue Fassung bringt ein Migrationsskript, das mit Exitcode 1 endet. Geprüft wird danach, dass der Code auf dem alten Commit steht, die Datenbank den Inhalt von vor dem Update enthält, der Wartungszustand aufgehoben ist (weil der alte Stand antwortet) und im Protokoll `FEHLER` steht.
|
||||
- Die Reihenfolge der Schritte ist als Liste festgehalten, nicht nur ihr Ergebnis: Vorprüfung, Ziel, Sicherung, Wartung, Checkout, Fassung prüfen, Abhängigkeiten, Migration, Cache, Rauchtest, Fertig. Wer sie umstellt, bricht den Test.
|
||||
- Geprüft ist außerdem, dass die Sicherung vor dem Checkout entsteht, dass sie die Datenbank enthält und den Medienordner **nicht**, und dass Datenbank und Medienordner der Installation unverändert bleiben.
|
||||
- **Am echten Repository durchgespielt**, nicht nur im Test – siehe den Bericht in STATUS: Tag v1.0.0 auf `git.kant-it.de` gesetzt, Installation darauf gestellt, v1.0.1 veröffentlicht, Update ausgeführt.
|
||||
- **Am echten Repository durchgespielt**, nicht nur im Test: `git.kant-it.de/kantarci/KantSites`, Tag `v1.0.0` gesetzt, die Entwicklungsinstallation darauf gestellt (`git checkout v1.0.0`), `v1.0.1` veröffentlicht und `php bin/console cms:update` ausgeführt. Ergebnis: elf Schritte, alle ok, in dieser Reihenfolge – Vorprüfung, Ziel (v1.0.1 von 52f0ab38, Fassung 1.0.0), Sicherung (220 KB nach `var/backups/update-20260912-181159`, darin `cms.db`, `.env`, `.env.local`, kein Medienordner), Wartungszustand, Checkout, Fassung geprüft, Abhängigkeiten (Composer fehlt, vendor/ bleibt liegen), Migration („Already at the latest version“), Cache, Rauchtest („Datenbank erreichbar, Migration 20260912001200, Startseite /home lesbar, Fassung 1.0.1“), fertig. Danach: `VERSION` 1.0.1, `git describe` v1.0.1, kein Wartungszustand, `/` antwortet 200, `var/updates.log` enthält `OK v1.0.1 (11 Schritte)`.
|
||||
- Der Wartungszustand ist danach einzeln geprüft: eingeschaltet antwortet `/` mit **503 und `Retry-After: 600`** und der Wartungsseite, `/admin/login` mit 200; ausgeschaltet wieder 200.
|
||||
- **Nicht geprüft:** MySQL (dafür fehlt die Installation, siehe `docs/UPDATE.md`, „Was anders wurde“), ein Hosting ohne `proc_open` (die Vorprüfung meldet es, der Fall selbst ist nicht durchgespielt), und ein Update über die Weboberfläche unter einem knappen Zeitlimit.
|
||||
|
||||
## Ergänzung: Statistik, 12.09.2026
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -25,6 +25,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- **Designer ausgebaut** (ADR 036, Dokumentschema v15): **93 Gestaltungseigenschaften** statt 50, je Gerät und teils für Hover. Neu: Bild-Eigenschaften, die auf das Bild *im* Block wirken (Ausschnitt, Bildmitte, Seitenverhältnis, Höhe, Ecken, Rahmen), Positionierung mit z-index, getrennte Zeilen- und Spaltenabstände, Flex-Eigenschaften, Innen- und Textschatten, acht Bildfilter, Mischmodus, Zusatzrahmen. Neun Einblendungen mit Verzögerung, sieben Hover-Effekte, alle innerhalb von `prefers-reduced-motion`. Drei neue Bausteine: **Symbol**, **Fortschritt** (mit `role="meter"`) und **Slider** als Scrollbereich mit Einrasten – ohne JavaScript, ohne Autoplay. „Zeile, 2/3/4 Spalten“ legt eine Zeile samt Spalten auf einen Klick an. Der Vergleich mit Flatsome steht in `docs/DESIGNER-VERGLEICH.md`, mit dem was fehlt und warum.
|
||||
- **Zwei Fehler dabei gefunden und behoben:** Rahmenstärken je Seite erzeugten `border-width-top` – kein gültiges CSS, vom Browser stillschweigend verworfen, also **ohne jede Wirkung**. Und eine Rundung auf einem Bildblock rundete den Rahmen, nicht das Bild.
|
||||
- **Update über Git** (ADR 035, Arbeitspaket N/UPDATE.md): `cms:update` und ein Bildschirm „System“ zeigen Fassung, Commit, verfügbaren Tag und eine Vorprüfung, die jede Bedingung einzeln nennt – unter anderem, ob Datenbank und Medienordner von Git ausgenommen sind. Der Ablauf: sichern, Wartungszustand (503 mit `Retry-After`, `/admin` bleibt offen), Checkout des Tags, Fassung prüfen, Composer, Migration, Cache, Rauchtest als eigener Prozess (`cms:health`). Scheitert ein Schritt, wird auf den vorherigen Commit zurückgesetzt, die Datenbank nur dann zurückgeschrieben, wenn die Migration schon begonnen hatte – und wenn der alte Stand antwortet, geht die Website wieder online. `--check`, `--dry-run`, `--to=v1.2.0` und `--maintenance=an|aus` gibt es auf der Konsole.
|
||||
- **Statistik** (ADR 034, Arbeitspaket L): Aufrufe je Tag und Stunde, meistaufgerufene Seiten, Verweise von außen und nicht gefundene Adressen, als CSV exportierbar. Gespeichert wird **ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain)**, kein Datensatz je Aufruf – keine IP-Adresse, keine Kennung, kein Cookie, auch nicht gehasht. Deshalb braucht diese Messung keine Einwilligung. Diagramme zeichnet der Server als SVG, ohne Skript. Was sie nicht kann, steht auf dem Bildschirm: Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Nach 60 Tagen werden Stunden zu Tageswerten verschmolzen, nach 425 Tagen gelöscht (`cms:stats:prune`).
|
||||
- **Einwilligung** (ADR 032, Dokumentschema v14): serverseitig gerendertes Banner ohne JavaScript, „Alle akzeptieren“ und „Nur notwendige“ gleich groß nebeneinander, Auswahl je Gruppe im Aufklappfeld. Die Entscheidung steht in einem eigenen Cookie – keine Kennung, kein Serverabgleich, ein halbes Jahr. Unter `/einwilligung` jederzeit änderbar, von jeder Seite verlinkt. **Der neue Block „Externer Inhalt“ lädt ohne Einwilligung nichts:** kein iframe, keine Adresse im Markup, `frame-src 'none'`. Der Platzhalter nennt den Anbieter und gewährt auf Knopfdruck genau diese Gruppe. Ist der Hinweis abgeschaltet, laden die Inhalte – sonst würde das Abschalten jede Seite stillschweigend leeren.
|
||||
@@ -73,6 +75,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
|
||||
- Designer geprüft: 16 Datensätze prüfen, dass jede Eigenschaftsform genau die versprochene Deklaration erzeugt – Rahmen je Seite in der richtigen Schreibweise, Ecken, Positionierung, Flex, getrennte Abstände, Innenschatten, Textschatten, Filter in fester Reihenfolge, Mischmodus, und die Bild-Eigenschaften in einer zweiten Regel `#id img{…}`. Jeder davon muss außerdem den Validator überleben, sonst bietet der Editor etwas an, das nicht gespeichert werden kann. Ein Test hält Schema und Editorpanel zusammen: eine Gruppe, die das Panel nicht kennt, würde ihre Eigenschaften in einem unbenannten Aufklappfeld verstecken.
|
||||
- Neue Bausteine geprüft: vier Darstellungen (dekoratives Symbol vor Screenreadern verborgen, sprechendes Symbol mit Text, Fortschritt mit `role="meter"` und Wert im Markup, Slider mit Klasse je Gerät) und acht abgewiesene Eingaben (Symbolgröße außerhalb der Liste, unbekanntes Symbol, sprechendes Symbol ohne Beschriftung, dekoratives mit Beschriftung, Fortschritt über 100, Fortschritt als Text, Slider mit sieben Folien, Slider ohne Gerätangabe). Keiner der drei braucht ein Skript: die Seite behält `script-src 'none'`.
|
||||
- Updater geprüft: sechs Tests gegen echte Git-Repositorys in einem Temp-Verzeichnis – Vorprüfung benennt geänderte Dateien und eine nicht ausgenommene Datenbank, ein Update wechselt den Tag in der geprüften Reihenfolge und hebt den Wartungszustand auf, ein gescheiterter Schritt setzt Code **und** Datenbank zurück und bringt die Website auf dem alten Stand wieder online, ein Probelauf verändert nichts, ein unbekannter Tag wird beim Checkout abgewiesen, und eine bereits installierte Fassung ist kein Fehler. Zusätzlich am echten Repository durchgespielt (siehe QUALITY).
|
||||
- Statistik geprüft: 32 Tests, darunter die Spaltenliste der Tabelle gegen die Zusage „keine Spalte, aus der eine Person hervorgeht“, 14 Anfragearten (Verwaltung, Bild, Stylesheet, Feed, Weiterleitung, POST, Googlebot, Überwachungsdienst, Headless, curl – alle nicht gezählt), sieben Verweisformen (nur der Host bleibt, die eigene Domain gilt nicht als Verweis), sechs Adressformen (Query verworfen, erfundene 404 gesammelt) und das Verschmelzen und Löschen alter Tage. Dazu durch echte Anfragen: fünf Aufrufe werden ein Zähler, ein 404 zählt nicht als Aufruf, Verwaltung und Feed zählen nicht.
|
||||
- Einwilligung geprüft: 26 PHP-Tests, darunter sieben Cookiezustände (gewährt, abgelehnt, andere Gruppe, abgelaufen, ältere Fassung der Frage, Unsinn, unbekannte Gruppe) und zehn abgewiesene Einbettungsadressen (http, protokollrelativ, `javascript:`, `data:`, mit Port, mit Zugangsdaten, Leerzeichen im Host, ohne Titel, unbekannte Gruppe, zu geringe Höhe). Im Browsertest zusätzlich: Banner erscheint, kein iframe und keine Adresse im Markup, Einwilligen über den Platzhalter lädt den Inhalt, Widerruf hält ihn wieder zurück, axe ohne Verstöße auf Banner und Einwilligungsseite.
|
||||
|
||||
@@ -600,3 +600,4 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.check-bad { color: var(--danger) }
|
||||
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||
.shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) }
|
||||
@@ -14,6 +14,7 @@ export const GROUPS = {
|
||||
spacing: 'Abstand',
|
||||
size: 'Größe',
|
||||
border: 'Rahmen',
|
||||
image: 'Bild',
|
||||
layout: 'Layout',
|
||||
effect: 'Wirkung',
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', 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' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', embed: '⬓' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', 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' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
|
||||
// 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.
|
||||
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
||||
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
|
||||
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
|
||||
export const blockIcon = block => icons[block?.type] ?? '❔';
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem'].includes(block?.type);
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type);
|
||||
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
|
||||
// Containers that accept only one kind of child, mirrored by the server side validator.
|
||||
export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' };
|
||||
@@ -50,6 +50,9 @@ export function createBlock(type) {
|
||||
if (type === 'partialref') block.props = { partial: '' };
|
||||
// An embed starts empty and blocked: no address, so nothing loads before someone enters one.
|
||||
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 };
|
||||
if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true };
|
||||
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||
return block;
|
||||
}
|
||||
@@ -64,6 +67,7 @@ export function insertBlock(document, type, selected) {
|
||||
if (type === 'breadcrumb') upgradeDocument(document, 9);
|
||||
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'embed') upgradeDocument(document, 14);
|
||||
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
|
||||
let target = locate(document.blocks, selected);
|
||||
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
|
||||
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
|
||||
@@ -168,6 +172,26 @@ export function insertMedia(document, selected, items) {
|
||||
return parent.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row with a given number of columns, inserted where the selection is.
|
||||
*
|
||||
* A row on its own looks like nothing, which is why "add a row" was hard to find: what people
|
||||
* want is a row that already has columns in it.
|
||||
*/
|
||||
export function insertRow(document, columns, selected) {
|
||||
const count = Math.max(1, Math.min(6, Number(columns) || 2));
|
||||
const names = { 1: 'one', 2: 'two', 3: 'three', 4: 'four' };
|
||||
const row = createBlock('row');
|
||||
row.layout = { design: { desktop: { columns: names[count] ?? 'auto', gap: 'medium' }, mobile: { columns: 'one' } } };
|
||||
row.children = Array.from({ length: count }, () => createBlock('column'));
|
||||
upgradeDocument(document);
|
||||
const target = locate(document.blocks, selected);
|
||||
if (!target) document.blocks.push(row);
|
||||
else if (canContain(target.block, row)) (target.block.children ??= []).push(row);
|
||||
else target.siblings.splice(target.index + 1, 0, row);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
export function insertPreset(document, name, selected) {
|
||||
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
|
||||
const section = createBlock('section');
|
||||
|
||||
@@ -204,3 +204,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.canvas-panel{background:#e7ebe5}
|
||||
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
|
||||
.icon-button{border-radius:8px}
|
||||
.row-library{display:grid;gap:6px;margin-bottom:14px}
|
||||
.row-library button{display:flex;align-items:center;gap:10px;min-height:38px;padding:8px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
|
||||
.row-library button:hover:not(:disabled){border-color:var(--accent)}
|
||||
.row-library span{letter-spacing:-2px;font-size:13px}
|
||||
+43
-5
@@ -1,10 +1,11 @@
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { createRichText } from './rich-text.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];
|
||||
|
||||
const root = document.querySelector('#editor');
|
||||
const $ = selector => document.querySelector(selector);
|
||||
@@ -61,6 +62,7 @@ function controls() {
|
||||
$('#outdent').disabled ||= !current.parent;
|
||||
}
|
||||
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#rows').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
@@ -161,7 +163,11 @@ function renderProperties() {
|
||||
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
||||
if (!current) return;
|
||||
if (options.layout) (current.layout ??= {})[key] = next;
|
||||
else if (selected) { (current.props ??= {})[key] = next; if (key === 'decorative' && next) current.props.alt = ''; }
|
||||
else if (selected) {
|
||||
(current.props ??= {})[key] = next;
|
||||
// Decorative means "no text": an image loses its alternative text, an icon its label.
|
||||
if (key === 'decorative' && next) { current.props.alt = ''; if (current.type === 'icon') current.props.label = ''; }
|
||||
}
|
||||
else current[key] = next;
|
||||
if (key === 'caption') upgradeDocument(doc, 3);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
@@ -237,6 +243,25 @@ function renderProperties() {
|
||||
if (block.type === 'spacer') field('Höhe', 'size', block.props.size, { choices: { small: 'Klein', medium: 'Mittel', large: 'Groß', huge: 'Sehr groß' } });
|
||||
if (block.type === 'menu') field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', footer: 'Fußzeile' }, help: 'Die Einträge werden unter Menüs gepflegt.' });
|
||||
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
|
||||
if (block.type === 'icon') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
// number, so the value is stored as a number: the server wants an int, not "48".
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||||
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 === '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.' });
|
||||
field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true });
|
||||
}
|
||||
if (block.type === 'slider') {
|
||||
for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) {
|
||||
field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined });
|
||||
}
|
||||
field('Einrasten', 'snap', block.props.snap, { checkbox: true, help: 'Der Slider ist ein Scrollbereich – ohne JavaScript, ohne Autoplay. Blättern geht mit Finger, Trackpad, Mausrad und Tastatur.' });
|
||||
}
|
||||
if (block.type === 'embed') {
|
||||
field('Gruppe', 'group', block.props.group, { choices: { external: 'Externe Inhalte', statistics: 'Statistik' }, help: 'Bestimmt, welche Einwilligung dieser Inhalt braucht. Vor der Einwilligung wird nichts geladen – kein Bild, kein Skript, keine Anfrage an den Anbieter.' });
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Was hier eingebettet ist, in einem Satz. Screenreader lesen das als Namen des Rahmens vor, deshalb nicht leer lassen.' });
|
||||
@@ -377,8 +402,15 @@ function renderLegacyTokens(block, panel) {
|
||||
function renderLegacyEffects(block, panel) {
|
||||
for (const [key, label, choices] of [
|
||||
['shadow', 'Schatten', { none: 'Keiner', soft: 'Weich', strong: 'Kräftig' }],
|
||||
['hover', 'Hover-Effekt', { none: 'Keiner', lift: 'Leicht anheben', zoom: 'Bild vergrößern' }],
|
||||
['entrance', 'Einblendung beim Laden', { none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten einblenden' }],
|
||||
['hover', 'Hover-Effekt', {
|
||||
none: 'Keiner', lift: 'Leicht anheben', sink: 'Leicht absenken', zoom: 'Bild vergrößern',
|
||||
glow: 'Leuchten', tilt: 'Leicht kippen', 'grow-border': 'Rahmen einfahren',
|
||||
}],
|
||||
['entrance', 'Einblendung beim Laden', {
|
||||
none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten', sink: 'Von oben', left: 'Von links',
|
||||
right: 'Von rechts', zoom: 'Heranzoomen', flip: 'Kippen', blur: 'Aus der Unschärfe',
|
||||
}],
|
||||
['delay', 'Verzögerung der Einblendung', { none: 'Keine', short: '0,15 s', medium: '0,3 s', long: '0,6 s' }],
|
||||
]) {
|
||||
const labelEl = document.createElement('label'); labelEl.textContent = label; labelEl.htmlFor = `effect-${key}`;
|
||||
const select = document.createElement('select'); select.id = labelEl.htmlFor;
|
||||
@@ -387,7 +419,7 @@ function renderLegacyEffects(block, panel) {
|
||||
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
|
||||
panel.append(labelEl, select);
|
||||
}
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder.'; panel.append(note);
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder. Einblendungen laufen beim Laden der Seite, nicht beim Scrollen: eine öffentliche Seite führt kein JavaScript aus.'; panel.append(note);
|
||||
}
|
||||
|
||||
function chooseMedia(multiple) {
|
||||
@@ -611,6 +643,12 @@ $('#library').addEventListener('click', event => {
|
||||
if (!type || busy || !history) return;
|
||||
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
|
||||
});
|
||||
// A row with columns, in one click: the piece people actually look for when they want a row.
|
||||
$('#rows').addEventListener('click', event => {
|
||||
const columns = event.target.closest('[data-row]')?.dataset.row;
|
||||
if (!columns || busy || !history) return;
|
||||
selected = history.change(doc => insertRow(doc, columns, selected)); propertyPanel = 'design'; changed();
|
||||
});
|
||||
$('#presets').addEventListener('click', event => {
|
||||
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||||
if (!name || busy || !history) return;
|
||||
|
||||
@@ -3,3 +3,36 @@
|
||||
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}.fx-entrance-fade{animation:kant-fade .55s ease-out backwards}.fx-entrance-rise{animation:kant-rise .55s ease-out backwards}@keyframes kant-fade{from{opacity:.35}to{opacity:1}}@keyframes kant-rise{from{opacity:.35;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}}
|
||||
|
||||
|
||||
/* Entrance animations and hover effects, added 12.09.2026.
|
||||
|
||||
Every animation stays inside the reduced-motion guard: whoever asked their system for less
|
||||
movement gets the finished state, not a softened animation. They play on load, because a public
|
||||
page runs no script and there is nothing to observe a scroll position with - that is a
|
||||
limitation, not an oversight, and it is written in the editor. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.fx-entrance-sink { animation: kant-sink .55s ease-out backwards }
|
||||
.fx-entrance-left { animation: kant-left .55s ease-out backwards }
|
||||
.fx-entrance-right { animation: kant-right .55s ease-out backwards }
|
||||
.fx-entrance-zoom { animation: kant-zoom .55s ease-out backwards }
|
||||
.fx-entrance-flip { animation: kant-flip .7s ease-out backwards }
|
||||
.fx-entrance-blur { animation: kant-unblur .6s ease-out backwards }
|
||||
.fx-delay-short { animation-delay: .15s }
|
||||
.fx-delay-medium { animation-delay: .3s }
|
||||
.fx-delay-long { animation-delay: .6s }
|
||||
|
||||
.fx-hover-sink { transition: transform .2s ease }
|
||||
.fx-hover-sink:hover { transform: translateY(3px) }
|
||||
.fx-hover-glow { transition: box-shadow .2s ease }
|
||||
.fx-hover-glow:hover { box-shadow: 0 0 0 4px #2c6b4f26, 0 10px 30px #1b2a2426 }
|
||||
.fx-hover-tilt { transition: transform .2s ease }
|
||||
.fx-hover-tilt:hover { transform: rotate(-1.2deg) scale(1.01) }
|
||||
.fx-hover-grow-border { transition: box-shadow .2s ease }
|
||||
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
|
||||
}
|
||||
@keyframes kant-sink { from { opacity: 0; transform: translateY(-18px) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-left { from { opacity: 0; transform: translateX(-24px) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-right { from { opacity: 0; transform: translateX(24px) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-zoom { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-flip { from { opacity: 0; transform: perspective(800px) rotateX(-12deg) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-unblur { from { opacity: 0; filter: blur(8px) } to { opacity: 1; filter: none } }
|
||||
@@ -160,3 +160,147 @@ body.consent-open{padding-bottom:260px}
|
||||
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:#f1f3ef}
|
||||
.legal-bar a{color:#a4510f}
|
||||
|
||||
/* An image takes the rounding of its figure. Without this a radius on an image block rounded the
|
||||
figure and left the picture square - which looked like the setting did nothing. */
|
||||
.block-image img, .block-gallery img, .block-logos img { border-radius: inherit }
|
||||
|
||||
/* A single icon, sized by class because inline styles are forbidden by the policy. */
|
||||
.block-icon{display:inline-flex;align-items:center;justify-content:center;color:var(--accent);line-height:0}
|
||||
.block-icon svg{width:100%;height:100%;display:block}
|
||||
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
|
||||
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
|
||||
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
|
||||
.icon-size-80{width:80px;height:80px}.icon-size-96{width:96px;height:96px}.icon-size-120{width:120px;height:120px}
|
||||
.icon-size-160{width:160px;height:160px}.icon-size-200{width:200px;height:200px}.icon-size-240{width:240px;height:240px}
|
||||
|
||||
/* A progress bar. The value is a class, not an inline style, for the same reason. */
|
||||
.block-progress{margin:0 0 22px}
|
||||
.progress-label{display:flex;justify-content:space-between;gap:16px;margin:0 0 8px;font-size:14px;font-weight:600}
|
||||
.progress-value{color:var(--muted);font-weight:400}
|
||||
.progress-track{height:10px;border-radius:999px;background:#e2e7e1;overflow:hidden}
|
||||
.progress-fill{height:100%;border-radius:999px;background:var(--accent)}
|
||||
.tone-ink .progress-track{background:#ffffff26}
|
||||
.tone-ink .progress-fill{background:var(--mint)}
|
||||
|
||||
/* A slider without a script: a scroll container with snapping. It keeps working when snapping is
|
||||
unsupported - then it is a strip you can scroll. No autoplay, ever. */
|
||||
.block-slider{display:grid;grid-auto-flow:column;gap:20px;overflow-x:auto;overscroll-behavior-x:contain;padding-bottom:12px;scrollbar-gutter:stable}
|
||||
.block-slider.slider-snap{scroll-snap-type:x mandatory}
|
||||
.slider-snap>.slider-slide{scroll-snap-align:start}
|
||||
.slider-slide{min-width:0}
|
||||
.block-slider:focus-visible{outline:3px solid #b85c12;outline-offset:6px}
|
||||
.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)}
|
||||
.slide-desktop-5{grid-auto-columns:calc(20% - 16px)}.slide-desktop-6{grid-auto-columns:calc(16.666% - 17px)}
|
||||
@media (max-width:1024px){
|
||||
.slide-tablet-1{grid-auto-columns:100%}.slide-tablet-2{grid-auto-columns:calc(50% - 10px)}
|
||||
.slide-tablet-3{grid-auto-columns:calc(33.333% - 14px)}.slide-tablet-4{grid-auto-columns:calc(25% - 15px)}
|
||||
.slide-tablet-5{grid-auto-columns:calc(20% - 16px)}.slide-tablet-6{grid-auto-columns:calc(16.666% - 17px)}
|
||||
}
|
||||
@media (max-width:650px){
|
||||
.slide-mobile-1{grid-auto-columns:100%}.slide-mobile-2{grid-auto-columns:calc(50% - 10px)}
|
||||
.slide-mobile-3{grid-auto-columns:calc(33.333% - 14px)}.slide-mobile-4{grid-auto-columns:calc(25% - 15px)}
|
||||
.slide-mobile-5{grid-auto-columns:calc(20% - 16px)}.slide-mobile-6{grid-auto-columns:calc(16.666% - 17px)}
|
||||
}
|
||||
.progress-fill-0{width:0%}
|
||||
.progress-fill-1{width:1%}
|
||||
.progress-fill-2{width:2%}
|
||||
.progress-fill-3{width:3%}
|
||||
.progress-fill-4{width:4%}
|
||||
.progress-fill-5{width:5%}
|
||||
.progress-fill-6{width:6%}
|
||||
.progress-fill-7{width:7%}
|
||||
.progress-fill-8{width:8%}
|
||||
.progress-fill-9{width:9%}
|
||||
.progress-fill-10{width:10%}
|
||||
.progress-fill-11{width:11%}
|
||||
.progress-fill-12{width:12%}
|
||||
.progress-fill-13{width:13%}
|
||||
.progress-fill-14{width:14%}
|
||||
.progress-fill-15{width:15%}
|
||||
.progress-fill-16{width:16%}
|
||||
.progress-fill-17{width:17%}
|
||||
.progress-fill-18{width:18%}
|
||||
.progress-fill-19{width:19%}
|
||||
.progress-fill-20{width:20%}
|
||||
.progress-fill-21{width:21%}
|
||||
.progress-fill-22{width:22%}
|
||||
.progress-fill-23{width:23%}
|
||||
.progress-fill-24{width:24%}
|
||||
.progress-fill-25{width:25%}
|
||||
.progress-fill-26{width:26%}
|
||||
.progress-fill-27{width:27%}
|
||||
.progress-fill-28{width:28%}
|
||||
.progress-fill-29{width:29%}
|
||||
.progress-fill-30{width:30%}
|
||||
.progress-fill-31{width:31%}
|
||||
.progress-fill-32{width:32%}
|
||||
.progress-fill-33{width:33%}
|
||||
.progress-fill-34{width:34%}
|
||||
.progress-fill-35{width:35%}
|
||||
.progress-fill-36{width:36%}
|
||||
.progress-fill-37{width:37%}
|
||||
.progress-fill-38{width:38%}
|
||||
.progress-fill-39{width:39%}
|
||||
.progress-fill-40{width:40%}
|
||||
.progress-fill-41{width:41%}
|
||||
.progress-fill-42{width:42%}
|
||||
.progress-fill-43{width:43%}
|
||||
.progress-fill-44{width:44%}
|
||||
.progress-fill-45{width:45%}
|
||||
.progress-fill-46{width:46%}
|
||||
.progress-fill-47{width:47%}
|
||||
.progress-fill-48{width:48%}
|
||||
.progress-fill-49{width:49%}
|
||||
.progress-fill-50{width:50%}
|
||||
.progress-fill-51{width:51%}
|
||||
.progress-fill-52{width:52%}
|
||||
.progress-fill-53{width:53%}
|
||||
.progress-fill-54{width:54%}
|
||||
.progress-fill-55{width:55%}
|
||||
.progress-fill-56{width:56%}
|
||||
.progress-fill-57{width:57%}
|
||||
.progress-fill-58{width:58%}
|
||||
.progress-fill-59{width:59%}
|
||||
.progress-fill-60{width:60%}
|
||||
.progress-fill-61{width:61%}
|
||||
.progress-fill-62{width:62%}
|
||||
.progress-fill-63{width:63%}
|
||||
.progress-fill-64{width:64%}
|
||||
.progress-fill-65{width:65%}
|
||||
.progress-fill-66{width:66%}
|
||||
.progress-fill-67{width:67%}
|
||||
.progress-fill-68{width:68%}
|
||||
.progress-fill-69{width:69%}
|
||||
.progress-fill-70{width:70%}
|
||||
.progress-fill-71{width:71%}
|
||||
.progress-fill-72{width:72%}
|
||||
.progress-fill-73{width:73%}
|
||||
.progress-fill-74{width:74%}
|
||||
.progress-fill-75{width:75%}
|
||||
.progress-fill-76{width:76%}
|
||||
.progress-fill-77{width:77%}
|
||||
.progress-fill-78{width:78%}
|
||||
.progress-fill-79{width:79%}
|
||||
.progress-fill-80{width:80%}
|
||||
.progress-fill-81{width:81%}
|
||||
.progress-fill-82{width:82%}
|
||||
.progress-fill-83{width:83%}
|
||||
.progress-fill-84{width:84%}
|
||||
.progress-fill-85{width:85%}
|
||||
.progress-fill-86{width:86%}
|
||||
.progress-fill-87{width:87%}
|
||||
.progress-fill-88{width:88%}
|
||||
.progress-fill-89{width:89%}
|
||||
.progress-fill-90{width:90%}
|
||||
.progress-fill-91{width:91%}
|
||||
.progress-fill-92{width:92%}
|
||||
.progress-fill-93{width:93%}
|
||||
.progress-fill-94{width:94%}
|
||||
.progress-fill-95{width:95%}
|
||||
.progress-fill-96{width:96%}
|
||||
.progress-fill-97{width:97%}
|
||||
.progress-fill-98{width:98%}
|
||||
.progress-fill-99{width:99%}
|
||||
.progress-fill-100{width:100%}
|
||||
@@ -25,6 +25,10 @@ final class DocumentValidator
|
||||
public const V11_TYPES = ['partialref', 'menu'];
|
||||
/** Schema v14: content from a third party, which stays unloaded until consent is given. */
|
||||
public const V14_TYPES = ['embed'];
|
||||
/** The sizes an icon can have; each one has a class in site.css. */
|
||||
public const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||||
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
|
||||
public const V15_TYPES = ['icon', 'progress', 'slider'];
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
@@ -34,7 +38,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], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], 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.');
|
||||
@@ -62,7 +66,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)) || $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)) || $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.');
|
||||
@@ -90,7 +94,14 @@ final class DocumentValidator
|
||||
foreach ($layout as $key => $value) {
|
||||
if ('effects' === $key && $schemaVersion >= 3) {
|
||||
$this->require(is_array($value), 'Effects must be an object.');
|
||||
$effects = ['shadow' => ['none', 'soft', 'strong'], 'hover' => ['none', 'lift', 'zoom'], 'entrance' => ['none', 'fade', 'rise']];
|
||||
$effects = [
|
||||
'shadow' => ['none', 'soft', 'strong'],
|
||||
'hover' => ['none', 'lift', 'zoom', 'sink', 'glow', 'tilt', 'grow-border'],
|
||||
// Entrance animations run when the page loads, not when the element scrolls
|
||||
// into view: that would need a script, and a public page runs none.
|
||||
'entrance' => ['none', 'fade', 'rise', 'sink', 'left', 'right', 'zoom', 'flip', 'blur'],
|
||||
'delay' => ['none', 'short', 'medium', 'long'],
|
||||
];
|
||||
foreach ($value as $effect => $token) {
|
||||
$this->require(isset($effects[$effect]) && in_array($token, $effects[$effect], true), 'Unknown effect.');
|
||||
}
|
||||
@@ -181,6 +192,34 @@ final class DocumentValidator
|
||||
// change reaches every page that links it and a page cannot alter a shared section.
|
||||
$this->require(is_string($props['partial'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80, 'Invalid section address.');
|
||||
}
|
||||
if ('icon' === $type) {
|
||||
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
||||
// A fixed list, because the size becomes a class: a value without a class would leave
|
||||
// an icon with no dimensions at all, and an SVG without dimensions is invisible.
|
||||
$this->require(in_array($props['size'] ?? null, self::ICON_SIZES, true), sprintf('Die Symbolgröße ist eine aus %s.', implode(', ', self::ICON_SIZES)));
|
||||
// A decorative icon is hidden from assistive technology; one that carries meaning
|
||||
// needs a label, or a screen reader announces nothing at all.
|
||||
$this->require(is_bool($props['decorative'] ?? null), 'Der Zweck des Symbols ist anzugeben.');
|
||||
$this->text($props['label'] ?? null, 120, $props['decorative']);
|
||||
$this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.');
|
||||
if ('' !== ($props['href'] ?? '')) {
|
||||
$this->url($props['href']);
|
||||
}
|
||||
}
|
||||
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.');
|
||||
$this->require(is_bool($props['showValue'] ?? null), 'Die Anzeige des Werts ist anzugeben.');
|
||||
}
|
||||
if ('slider' === $type) {
|
||||
// The slider is a scroll container: how many slides are visible per device is the
|
||||
// only thing it stores, the slides themselves are ordinary blocks.
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$key = $device.'Visible';
|
||||
$this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key));
|
||||
}
|
||||
$this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.');
|
||||
}
|
||||
if ('embed' === $type) {
|
||||
// Everything about the embed is validated here, because the address ends up in an
|
||||
// iframe and in the content security policy of the page.
|
||||
@@ -236,7 +275,7 @@ final class DocumentValidator
|
||||
$children = $block['children'] ?? [];
|
||||
$this->require(is_array($children) && array_is_list($children), 'Children must be a list.');
|
||||
// A plugin block may nest content; keeping its children is part of preserving the page.
|
||||
$container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem'], true);
|
||||
$container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'], true);
|
||||
$this->require($container || [] === $children, 'Leaf blocks cannot contain children.');
|
||||
// Containers that only accept one kind of child, so the rendered markup stays valid.
|
||||
$only = ['gallery' => 'image', 'logos' => 'image', 'accordion' => 'accordionitem', 'tabs' => 'tabitem'];
|
||||
|
||||
@@ -111,6 +111,12 @@ final class StyleCompiler
|
||||
if ('' !== $base) {
|
||||
$rules .= sprintf('#%s{%s}', $id, $base);
|
||||
}
|
||||
// Some properties belong on the images inside the block, not on the block: object-fit
|
||||
// on a figure does nothing, and a radius on the figure leaves the image square.
|
||||
$images = $this->declarations($values, 'img');
|
||||
if ('' !== $images) {
|
||||
$rules .= sprintf('#%s img{%s}', $id, $images);
|
||||
}
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover']) : '';
|
||||
if ('' !== $hover) {
|
||||
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
||||
@@ -127,8 +133,12 @@ final class StyleCompiler
|
||||
return $css;
|
||||
}
|
||||
|
||||
/** Turns one validated value set into CSS declarations. */
|
||||
private function declarations(array $values): string
|
||||
/**
|
||||
* Turns one validated value set into CSS declarations.
|
||||
*
|
||||
* @param string $target 'self' for the block, 'img' for the images inside it
|
||||
*/
|
||||
private function declarations(array $values, string $target = 'self'): string
|
||||
{
|
||||
$properties = StyleSchema::properties();
|
||||
$out = [];
|
||||
@@ -137,6 +147,9 @@ final class StyleCompiler
|
||||
continue;
|
||||
}
|
||||
$property = $properties[$key];
|
||||
if ($target !== ($property['target'] ?? 'self')) {
|
||||
continue;
|
||||
}
|
||||
if (null === $property['css']) {
|
||||
continue; // Composed below out of several values.
|
||||
}
|
||||
@@ -148,16 +161,29 @@ final class StyleCompiler
|
||||
continue;
|
||||
}
|
||||
if ($property['sides'] ?? false) {
|
||||
// The spelling comes from the schema: "border-top-width", not "border-width-top".
|
||||
$pattern = $property['sideCss'] ?? $property['css'].'-%s';
|
||||
foreach ($value as $side => $single) {
|
||||
$out[] = sprintf('%s-%s:%s', $property['css'], $side, $this->css($property, (string) $single));
|
||||
$out[] = sprintf('%s:%s', sprintf($pattern, $side), $this->css($property, (string) $single));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$out[] = sprintf('%s:%s', $property['css'], $this->css($property, (string) $value));
|
||||
}
|
||||
foreach ([$this->background($values), $this->shadow($values), $this->transform($values), $this->grid($values), $this->transition($values)] as $composed) {
|
||||
if ('' !== $composed) {
|
||||
$out[] = $composed;
|
||||
// The composed declarations describe the block itself; none of them has an image variant.
|
||||
if ('self' === $target) {
|
||||
foreach ([
|
||||
$this->background($values),
|
||||
$this->shadow($values),
|
||||
$this->textShadow($values),
|
||||
$this->filter($values),
|
||||
$this->transform($values),
|
||||
$this->grid($values),
|
||||
$this->transition($values),
|
||||
] as $composed) {
|
||||
if ('' !== $composed) {
|
||||
$out[] = $composed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +240,8 @@ final class StyleCompiler
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'box-shadow:%s %s %s %s %s',
|
||||
'box-shadow:%s%s %s %s %s %s',
|
||||
'innen' === ($values['shadowInset'] ?? 'aussen') ? 'inset ' : '',
|
||||
$values['shadowX'] ?? '0',
|
||||
$values['shadowY'] ?? '0',
|
||||
$values['shadowBlur'] ?? '0',
|
||||
@@ -223,6 +250,52 @@ final class StyleCompiler
|
||||
);
|
||||
}
|
||||
|
||||
/** A text shadow, from the same kind of parts as the box shadow. */
|
||||
private function textShadow(array $values): string
|
||||
{
|
||||
$parts = array_intersect_key($values, array_flip(['textShadowX', 'textShadowY', 'textShadowBlur', 'textShadowColor']));
|
||||
if ([] === $parts) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'text-shadow:%s %s %s %s',
|
||||
$values['textShadowX'] ?? '0',
|
||||
$values['textShadowY'] ?? '0',
|
||||
$values['textShadowBlur'] ?? '0',
|
||||
$this->css(['type' => 'color'], (string) ($values['textShadowColor'] ?? '#00000040')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The image filters, as one declaration in a fixed order.
|
||||
*
|
||||
* The order matters for the result, so it is written here rather than taken from the order in
|
||||
* which someone happened to set the values.
|
||||
*/
|
||||
private function filter(array $values): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ([
|
||||
'blur' => 'blur(%spx)',
|
||||
'brightness' => 'brightness(%s)',
|
||||
'contrast' => 'contrast(%s)',
|
||||
'saturate' => 'saturate(%s)',
|
||||
'grayscale' => 'grayscale(%s)',
|
||||
'sepia' => 'sepia(%s)',
|
||||
'invert' => 'invert(%s)',
|
||||
] as $key => $format) {
|
||||
if (isset($values[$key])) {
|
||||
$parts[] = sprintf($format, (float) $values[$key]);
|
||||
}
|
||||
}
|
||||
if (isset($values['hueRotate'])) {
|
||||
$parts[] = sprintf('hue-rotate(%ddeg)', (int) $values['hueRotate']);
|
||||
}
|
||||
|
||||
return [] === $parts ? '' : 'filter:'.implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function transform(array $values): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
@@ -25,7 +25,12 @@ final class StyleSchema
|
||||
* group – where the property appears in the editor
|
||||
* type – length | length-auto | color | number | choice | text
|
||||
* css – the CSS property it becomes; sides/corners expand to four
|
||||
* label – German label for the editor.
|
||||
* label – German label for the editor
|
||||
* target – 'img' applies the declaration to the images inside the block instead of the block
|
||||
* itself. object-fit on a figure does nothing; it belongs on the image.
|
||||
* sideCss – how one side is spelled. "padding-top" follows the property name, but a border
|
||||
* width is "border-top-width" and not "border-width-top" - written out, because
|
||||
* guessing it wrong produces a declaration the browser silently drops.
|
||||
*
|
||||
* @return array<string, array{group: string, type: string, css: string|array, label: string, values?: array<string, string>, min?: float, max?: float, step?: float, sides?: bool, hover?: bool}>
|
||||
*/
|
||||
@@ -77,8 +82,8 @@ final class StyleSchema
|
||||
'overlayOpacity' => ['group' => 'background', 'type' => 'number', 'css' => null, 'label' => 'Deckkraft der Überlagerung', 'min' => 0, 'max' => 1, 'step' => 0.05],
|
||||
|
||||
// ---- Abstand ----------------------------------------------------------------
|
||||
'padding' => ['group' => 'spacing', 'type' => 'length', 'css' => 'padding', 'label' => 'Innenabstand', 'sides' => true],
|
||||
'margin' => ['group' => 'spacing', 'type' => 'length-auto', 'css' => 'margin', 'label' => 'Außenabstand', 'sides' => true],
|
||||
'padding' => ['group' => 'spacing', 'type' => 'length', 'css' => 'padding', 'label' => 'Innenabstand', 'sides' => true, 'sideCss' => 'padding-%s'],
|
||||
'margin' => ['group' => 'spacing', 'type' => 'length-auto', 'css' => 'margin', 'label' => 'Außenabstand', 'sides' => true, 'sideCss' => 'margin-%s'],
|
||||
|
||||
// ---- Größe ------------------------------------------------------------------
|
||||
'width' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'width', 'label' => 'Breite'],
|
||||
@@ -92,12 +97,38 @@ final class StyleSchema
|
||||
]],
|
||||
|
||||
// ---- Rahmen -----------------------------------------------------------------
|
||||
'borderWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'border-width', 'label' => 'Rahmenstärke', 'sides' => true],
|
||||
'borderWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'border-width', 'label' => 'Rahmenstärke', 'sides' => true, 'sideCss' => 'border-%s-width'],
|
||||
'borderStyle' => ['group' => 'border', 'type' => 'choice', 'css' => 'border-style', 'label' => 'Rahmenart', 'values' => [
|
||||
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
|
||||
]],
|
||||
'borderColor' => ['group' => 'border', 'type' => 'color', 'css' => 'border-color', 'label' => 'Rahmenfarbe', 'hover' => true],
|
||||
'borderRadius' => ['group' => 'border', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Ecken', 'corners' => true],
|
||||
'outlineWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'outline-width', 'label' => 'Zusatzrahmen Stärke'],
|
||||
'outlineColor' => ['group' => 'border', 'type' => 'color', 'css' => 'outline-color', 'label' => 'Zusatzrahmen Farbe', 'hover' => true],
|
||||
'outlineOffset' => ['group' => 'border', 'type' => 'length', 'css' => 'outline-offset', 'label' => 'Zusatzrahmen Abstand'],
|
||||
'outlineStyle' => ['group' => 'border', 'type' => 'choice', 'css' => 'outline-style', 'label' => 'Zusatzrahmen Art', 'values' => [
|
||||
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
|
||||
]],
|
||||
|
||||
// ---- Bild: wirkt auf die Bilder im Block, nicht auf den Block ----------------
|
||||
'objectFit' => ['group' => 'image', 'type' => 'choice', 'css' => 'object-fit', 'label' => 'Bildausschnitt', 'target' => 'img', 'values' => [
|
||||
'cover' => 'Füllen und beschneiden', 'contain' => 'Vollständig zeigen', 'fill' => 'Verzerren', 'none' => 'Originalgröße', 'scale-down' => 'Nur verkleinern',
|
||||
]],
|
||||
'objectPosition' => ['group' => 'image', 'type' => 'choice', 'css' => 'object-position', 'label' => 'Bildmitte', 'target' => 'img', 'values' => [
|
||||
'center' => 'Mitte', 'top' => 'Oben', 'bottom' => 'Unten', 'left' => 'Links', 'right' => 'Rechts',
|
||||
'top left' => 'Oben links', 'top right' => 'Oben rechts', 'bottom left' => 'Unten links', 'bottom right' => 'Unten rechts',
|
||||
]],
|
||||
'imageRatio' => ['group' => 'image', 'type' => 'choice', 'css' => 'aspect-ratio', 'label' => 'Seitenverhältnis', 'target' => 'img', 'values' => [
|
||||
'auto' => 'Wie das Bild', '1/1' => 'Quadrat 1:1', '4/3' => 'Klassisch 4:3', '3/2' => 'Foto 3:2', '16/9' => 'Breitbild 16:9',
|
||||
'21/9' => 'Panorama 21:9', '3/4' => 'Hochkant 3:4', '2/3' => 'Hochkant 2:3', '9/16' => 'Hochkant 9:16',
|
||||
]],
|
||||
'imageHeight' => ['group' => 'image', 'type' => 'length-auto', 'css' => 'height', 'label' => 'Bildhöhe', 'target' => 'img'],
|
||||
'imageRadius' => ['group' => 'image', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Bildecken', 'target' => 'img', 'corners' => true],
|
||||
'imageBorderWidth' => ['group' => 'image', 'type' => 'length', 'css' => 'border-width', 'label' => 'Bildrahmen Stärke', 'target' => 'img', 'sides' => true, 'sideCss' => 'border-%s-width'],
|
||||
'imageBorderColor' => ['group' => 'image', 'type' => 'color', 'css' => 'border-color', 'label' => 'Bildrahmen Farbe', 'target' => 'img'],
|
||||
'imageBorderStyle' => ['group' => 'image', 'type' => 'choice', 'css' => 'border-style', 'label' => 'Bildrahmen Art', 'target' => 'img', 'values' => [
|
||||
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
|
||||
]],
|
||||
|
||||
// ---- Layout -----------------------------------------------------------------
|
||||
'display' => ['group' => 'layout', 'type' => 'choice', 'css' => 'display', 'label' => 'Anzeige', 'values' => [
|
||||
@@ -116,6 +147,22 @@ final class StyleSchema
|
||||
'gridColumns' => ['group' => 'layout', 'type' => 'number', 'css' => null, 'label' => 'Spalten', 'min' => 1, 'max' => 12, 'step' => 1],
|
||||
'gap' => ['group' => 'layout', 'type' => 'length', 'css' => 'gap', 'label' => 'Abstand zwischen Elementen'],
|
||||
'order' => ['group' => 'layout', 'type' => 'number', 'css' => 'order', 'label' => 'Reihenfolge', 'min' => -20, 'max' => 20, 'step' => 1],
|
||||
'rowGap' => ['group' => 'layout', 'type' => 'length', 'css' => 'row-gap', 'label' => 'Abstand zwischen Zeilen'],
|
||||
'columnGap' => ['group' => 'layout', 'type' => 'length', 'css' => 'column-gap', 'label' => 'Abstand zwischen Spalten'],
|
||||
'alignSelf' => ['group' => 'layout', 'type' => 'choice', 'css' => 'align-self', 'label' => 'Eigene Ausrichtung quer', 'values' => [
|
||||
'auto' => 'Wie die Gruppe', 'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'stretch' => 'Strecken', 'baseline' => 'Grundlinie',
|
||||
]],
|
||||
'flexGrow' => ['group' => 'layout', 'type' => 'number', 'css' => 'flex-grow', 'label' => 'Wachsen', 'min' => 0, 'max' => 12, 'step' => 1],
|
||||
'flexShrink' => ['group' => 'layout', 'type' => 'number', 'css' => 'flex-shrink', 'label' => 'Schrumpfen', 'min' => 0, 'max' => 12, 'step' => 1],
|
||||
'flexBasis' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'flex-basis', 'label' => 'Grundbreite'],
|
||||
'position' => ['group' => 'layout', 'type' => 'choice', 'css' => 'position', 'label' => 'Positionierung', 'values' => [
|
||||
'static' => 'Im Fluss', 'relative' => 'Relativ verschoben', 'absolute' => 'Freigestellt', 'sticky' => 'Klebt beim Scrollen',
|
||||
]],
|
||||
'top' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'top', 'label' => 'Abstand von oben'],
|
||||
'right' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'right', 'label' => 'Abstand von rechts'],
|
||||
'bottom' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'bottom', 'label' => 'Abstand von unten'],
|
||||
'left' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'left', 'label' => 'Abstand von links'],
|
||||
'zIndex' => ['group' => 'layout', 'type' => 'number', 'css' => 'z-index', 'label' => 'Ebene (vorn/hinten)', 'min' => -10, 'max' => 100, 'step' => 1],
|
||||
|
||||
// ---- Wirkung ----------------------------------------------------------------
|
||||
'opacity' => ['group' => 'effect', 'type' => 'number', 'css' => 'opacity', 'label' => 'Deckkraft', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
|
||||
@@ -128,10 +175,37 @@ final class StyleSchema
|
||||
'translateY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Verschieben senkrecht', 'hover' => true],
|
||||
'scale' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Skalierung', 'min' => 0.1, 'max' => 3, 'step' => 0.01, 'hover' => true],
|
||||
'rotate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Drehung', 'min' => -360, 'max' => 360, 'step' => 1, 'hover' => true],
|
||||
'shadowInset' => ['group' => 'effect', 'type' => 'choice', 'css' => null, 'label' => 'Schatten innen', 'values' => ['aussen' => 'Außen', 'innen' => 'Innen']],
|
||||
'textShadowX' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Textschatten waagerecht'],
|
||||
'textShadowY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Textschatten senkrecht'],
|
||||
'textShadowBlur' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Textschatten weich'],
|
||||
'textShadowColor' => ['group' => 'effect', 'type' => 'color', 'css' => null, 'label' => 'Textschattenfarbe'],
|
||||
'blur' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Weichzeichnen (px)', 'min' => 0, 'max' => 40, 'step' => 0.5, 'hover' => true],
|
||||
'brightness' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Helligkeit', 'min' => 0, 'max' => 3, 'step' => 0.05, 'hover' => true],
|
||||
'contrast' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Kontrast', 'min' => 0, 'max' => 3, 'step' => 0.05, 'hover' => true],
|
||||
'saturate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Farbsättigung', 'min' => 0, 'max' => 3, 'step' => 0.05, 'hover' => true],
|
||||
'grayscale' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Graustufen', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
|
||||
'sepia' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Sepia', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
|
||||
'hueRotate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Farbdrehung (Grad)', 'min' => -180, 'max' => 180, 'step' => 1, 'hover' => true],
|
||||
'invert' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Umkehren', 'min' => 0, 'max' => 1, 'step' => 0.05],
|
||||
'mixBlendMode' => ['group' => 'effect', 'type' => 'choice', 'css' => 'mix-blend-mode', 'label' => 'Mischmodus', 'values' => [
|
||||
'normal' => 'Normal', 'multiply' => 'Multiplizieren', 'screen' => 'Negativ multiplizieren', 'overlay' => 'Überlagern',
|
||||
'darken' => 'Abdunkeln', 'lighten' => 'Aufhellen', 'color-dodge' => 'Farbig abwedeln', 'difference' => 'Differenz',
|
||||
'exclusion' => 'Ausschluss', 'hue' => 'Farbton', 'saturation' => 'Sättigung', 'color' => 'Farbe', 'luminosity' => 'Leuchtkraft',
|
||||
]],
|
||||
'cursor' => ['group' => 'effect', 'type' => 'choice', 'css' => 'cursor', 'label' => 'Mauszeiger', 'values' => [
|
||||
'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],
|
||||
];
|
||||
}
|
||||
|
||||
/** Properties that are written onto the images inside a block instead of the block itself. */
|
||||
public static function imageTargets(): array
|
||||
{
|
||||
return array_keys(array_filter(self::properties(), static fn (array $p): bool => 'img' === ($p['target'] ?? null)));
|
||||
}
|
||||
|
||||
/** Properties that may also be given a hover value. */
|
||||
public static function hoverable(): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Ops\Updater;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* The running version, for the administration shell.
|
||||
*
|
||||
* A function rather than a global, so the templates get exactly this one read-only answer and not
|
||||
* a whole updater with an update() method on it.
|
||||
*/
|
||||
final class VersionExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private Updater $updater)
|
||||
{
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [new TwigFunction('kant_version', $this->updater->version(...))];
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,14 @@
|
||||
<main id="admin-main" class="editor-layout" tabindex="-1">
|
||||
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
|
||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
|
||||
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p>
|
||||
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element. Der Aufbau ist <strong>Sektion → Zeile → Spalte → Inhalt</strong>: eine Zeile hält Spalten nebeneinander.</p>
|
||||
<div id="rows" class="row-library">
|
||||
{% for count, label in {2: 'Zeile, 2 Spalten', 3: 'Zeile, 3 Spalten', 4: 'Zeile, 4 Spalten'} %}
|
||||
<button type="button" data-row="{{ count }}" disabled><span aria-hidden="true">{{ {2: '▥▥', 3: '▥▥▥', 4: '▥▥▥▥'}[count] }}</span> {{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="library" class="library">
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', partialref:'Verkn. Sektion', embed:'Externer Inhalt', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',partialref:'⧉',embed:'⬓',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',partialref:'⧉',embed:'⬓',icon:'◆',progress:'▰',slider:'⇄',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
</div>
|
||||
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
||||
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="shell-foot">
|
||||
<p class="shell-version">Fassung {{ kant_version() }}</p>
|
||||
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
|
||||
<form action="{{ path('admin_logout') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
||||
|
||||
@@ -125,6 +125,35 @@
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elseif type == 'icon' %}
|
||||
{# A single icon. Decorative ones are hidden from assistive technology; one that means
|
||||
something carries its label as text for screen readers. #}
|
||||
{% set iconBody %}{% include 'blocks/icon.html.twig' with {name: props.icon} only %}{% if not props.decorative %}<span class="visually-hidden">{{ props.label }}</span>{% endif %}{% endset %}
|
||||
{% if props.href|default('') %}
|
||||
<a id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}" href="{{ props.href }}"{{ props.decorative ? ' aria-hidden="true" tabindex="-1"' : '' }}>{{ iconBody }}</a>
|
||||
{% else %}
|
||||
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
|
||||
{% 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. #}
|
||||
<div id="{{ block.id }}" class="block-progress{{ designClasses }}">
|
||||
<p class="progress-label">{{ props.label }}{% if props.showValue %} <span class="progress-value">{{ props.value }} %</span>{% endif %}</p>
|
||||
<div class="progress-track" role="meter" aria-valuenow="{{ props.value }}" aria-valuemin="0" aria-valuemax="100" aria-label="{{ props.label }}">
|
||||
<div class="progress-fill progress-fill-{{ props.value }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% elseif type == 'slider' %}
|
||||
{# A slider without a script: a scroll container with scroll snapping. It works with a finger,
|
||||
a trackpad, a mouse wheel and the keyboard, and it degrades to a scrollable strip on a
|
||||
browser that ignores snapping. No autoplay - a moving carousel is something people ask to
|
||||
stop, not something a page should start on its own. #}
|
||||
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}"
|
||||
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
|
||||
{% for child in block.children|default([]) %}
|
||||
<div class="slider-slide">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'embed' %}
|
||||
{# Content from a third party. Until the group is granted, nothing of it is requested - no
|
||||
iframe, no image, no preconnect. That is the whole point of the consent question: the
|
||||
|
||||
@@ -14,6 +14,8 @@ use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\RichText;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\StyleSchema;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidMenu;
|
||||
@@ -1774,6 +1776,129 @@ final class CmsTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, array<string, mixed>, list<string>}> */
|
||||
public static function newBlocks(): iterable
|
||||
{
|
||||
yield 'a decorative icon is hidden from screen readers' => [
|
||||
'icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '', 'href' => ''],
|
||||
['class="block-icon icon-size-48', 'aria-hidden="true"'],
|
||||
];
|
||||
yield 'a meaningful icon carries its label as text' => [
|
||||
'icon', ['icon' => 'phone', 'size' => 32, 'decorative' => false, 'label' => 'Anrufen', 'href' => '/kontakt'],
|
||||
['<a id="i1"', 'href="/kontakt"', '<span class="visually-hidden">Anrufen</span>'],
|
||||
];
|
||||
yield 'a progress bar states its value in the markup' => [
|
||||
'progress', ['label' => 'Fortschritt', 'value' => 70, 'showValue' => true],
|
||||
['role="meter"', 'aria-valuenow="70"', 'progress-fill-70', '70 %'],
|
||||
];
|
||||
yield 'a slider is a scroll container with a class per device' => [
|
||||
'slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true],
|
||||
['class="block-slider slide-desktop-3 slide-tablet-2 slide-mobile-1 slider-snap"', 'tabindex="0"', 'role="group"'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('newBlocks')]
|
||||
public function testTheNewBlocksRenderWhatTheyPromise(string $type, array $props, array $expected): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit '.$type);
|
||||
$document['schemaVersion'] = 15;
|
||||
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
foreach ($expected as $needle) {
|
||||
self::assertStringContainsString($needle, $html, $type);
|
||||
}
|
||||
// No script is needed for any of them: the public page still forbids scripting.
|
||||
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, array<string, mixed>}> */
|
||||
public static function refusedNewBlocks(): iterable
|
||||
{
|
||||
// A size without a class would leave the icon without dimensions, so only the listed ones.
|
||||
yield 'icon size off the list' => ['icon', ['icon' => 'star', 'size' => 37, 'decorative' => true, 'label' => '']];
|
||||
yield 'unknown icon' => ['icon', ['icon' => 'erfunden', 'size' => 48, 'decorative' => true, 'label' => '']];
|
||||
yield 'meaningful icon without a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => false, 'label' => '']];
|
||||
yield 'decorative icon with a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => 'Doch etwas']];
|
||||
yield 'progress beyond a hundred' => ['progress', ['label' => 'X', 'value' => 140, 'showValue' => true]];
|
||||
yield 'progress with a text value' => ['progress', ['label' => 'X', 'value' => '70', 'showValue' => true]];
|
||||
yield 'slider with seven visible' => ['slider', ['desktopVisible' => 7, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true]];
|
||||
yield 'slider missing a device' => ['slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'snap' => true]];
|
||||
}
|
||||
|
||||
#[DataProvider('refusedNewBlocks')]
|
||||
public function testRefusedNewBlocksNeverReachADocument(string $type, array $props): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit '.$type);
|
||||
$document['schemaVersion'] = 15;
|
||||
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testTheNewBlocksNeedSchemaFifteen(): void
|
||||
{
|
||||
$document = DemoContent::starter('Zu alt');
|
||||
$document['schemaVersion'] = 14;
|
||||
$document['blocks'][] = ['id' => 'i1', 'type' => 'icon', 'props' => ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '']];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array<string, mixed>, string}> */
|
||||
public static function styleDeclarations(): iterable
|
||||
{
|
||||
// A border width per side is spelled border-top-width, not border-width-top. Written the
|
||||
// wrong way the browser drops the declaration, so per-side borders had no effect at all.
|
||||
yield 'border width per side' => [['borderWidth' => ['top' => '2px', 'left' => '4px']], '#b1{border-top-width:2px;border-left-width:4px}'];
|
||||
yield 'padding keeps its own spelling' => [['padding' => ['top' => '12px']], '#b1{padding-top:12px}'];
|
||||
yield 'corners' => [['borderRadius' => ['topLeft' => '18px']], '#b1{border-top-left-radius:18px}'];
|
||||
yield 'outline' => [['outlineWidth' => '3px', 'outlineColor' => '#2c6b4f', 'outlineOffset' => '2px'], '#b1{outline-width:3px;outline-color:#2c6b4f;outline-offset:2px}'];
|
||||
yield 'position and layer' => [['position' => 'absolute', 'top' => '0', 'right' => '12px', 'zIndex' => '5'], '#b1{position:absolute;top:0;right:12px;z-index:5}'];
|
||||
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}'];
|
||||
yield 'separate gaps' => [['rowGap' => '8px', 'columnGap' => '24px'], '#b1{row-gap:8px;column-gap:24px}'];
|
||||
yield 'inner shadow' => [['shadowY' => '4px', 'shadowBlur' => '12px', 'shadowInset' => 'innen'], '#b1{box-shadow:inset 0 4px 12px 0 #00000026}'];
|
||||
yield 'outer shadow stays outer' => [['shadowY' => '4px'], '#b1{box-shadow:0 4px 0 0 #00000026}'];
|
||||
yield 'text shadow' => [['textShadowY' => '2px', 'textShadowBlur' => '6px', 'textShadowColor' => '#00000080'], '#b1{text-shadow:0 2px 6px #00000080}'];
|
||||
// The filter order is fixed by the compiler, not by the order someone set the values in.
|
||||
yield 'filters in a fixed order' => [['grayscale' => '0.4', 'blur' => '2', 'brightness' => '1.1'], '#b1{filter:blur(2px) brightness(1.1) grayscale(0.4)}'];
|
||||
yield 'hue rotation' => [['hueRotate' => '-30'], '#b1{filter:hue-rotate(-30deg)}'];
|
||||
yield 'blend mode' => [['mixBlendMode' => 'multiply'], '#b1{mix-blend-mode:multiply}'];
|
||||
// These belong on the image inside the block: object-fit on a figure does nothing.
|
||||
yield 'image properties target the image' => [['objectFit' => 'cover', 'imageRatio' => '16/9'], '#b1 img{object-fit:cover;aspect-ratio:16/9}'];
|
||||
yield 'image radius and border' => [['imageRadius' => ['topLeft' => '12px'], 'imageBorderWidth' => ['top' => '3px'], 'imageBorderColor' => '#2c6b4f'], '#b1 img{border-top-left-radius:12px;border-top-width:3px;border-color:#2c6b4f}'];
|
||||
yield 'both at once, in two rules' => [['padding' => ['top' => '8px'], 'objectFit' => 'contain'], '#b1{padding-top:8px}#b1 img{object-fit:contain}'];
|
||||
}
|
||||
|
||||
#[DataProvider('styleDeclarations')]
|
||||
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
self::assertSame($expected, $compiler->compileDocument([
|
||||
'blocks' => [['id' => 'b1', 'type' => 'image', 'style' => ['desktop' => $style]]],
|
||||
]));
|
||||
// And what compiles has to survive the validator, or the editor could offer it in vain.
|
||||
$document = DemoContent::starter('Mit Stil');
|
||||
$document['schemaVersion'] = 10;
|
||||
$document['blocks'][0]['style'] = ['desktop' => $style];
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
|
||||
}
|
||||
|
||||
public function testTheStyleSchemaAndTheEditorPanelKnowTheSameGroups(): void
|
||||
{
|
||||
$groups = [];
|
||||
foreach (StyleSchema::properties() as $property) {
|
||||
$groups[$property['group']] = true;
|
||||
}
|
||||
$panel = (string) file_get_contents(__DIR__.'/../public/assets/design-panel.js');
|
||||
foreach (array_keys($groups) as $group) {
|
||||
// A group the panel does not know would hold its properties in an unnamed accordion.
|
||||
self::assertMatchesRegularExpression('/
|
||||
'.$group.': /', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
|
||||
}
|
||||
}
|
||||
|
||||
public function testALegalPathMustPointAtAPageThatExists(): void
|
||||
{
|
||||
foreach (['privacyPath', 'imprintPath'] as $key) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle } from '../public/assets/editor-model.js';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
|
||||
@@ -268,3 +268,37 @@ test('free styles inherit from the wider device, reset individually and leave no
|
||||
for (let i = 0; i < 7; i++) history.undo();
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
|
||||
test('a row arrives with its columns, inside the selected container', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
|
||||
// With a section selected, the row becomes a child of it - that is where people expect it.
|
||||
const rowId = history.change(doc => insertRow(doc, 3, 's1'));
|
||||
const row = locate(history.document.blocks, rowId);
|
||||
assert.equal(row.block.type, 'row');
|
||||
assert.equal(row.parent.id, 's1');
|
||||
assert.equal(row.block.children.length, 3);
|
||||
assert.ok(row.block.children.every(child => child.type === 'column'));
|
||||
assert.equal(row.block.layout.design.desktop.columns, 'three');
|
||||
assert.equal(row.block.layout.design.mobile.columns, 'one');
|
||||
// Column counts live in the design tokens, which need schema v2.
|
||||
assert.ok(history.document.schemaVersion >= 2);
|
||||
|
||||
// With a heading selected, the row is placed after it rather than inside it.
|
||||
const second = history.change(doc => insertRow(doc, 2, 'h1'));
|
||||
const placed = locate(history.document.blocks, second);
|
||||
assert.equal(placed.parent.id, 's1');
|
||||
assert.equal(placed.siblings[placed.index - 1].id, 'h1');
|
||||
assert.equal(placed.block.children.length, 2);
|
||||
|
||||
// Out of range counts are clamped instead of producing nonsense. Two statements, because
|
||||
// history.change replaces the document: reading blocks in the same expression reads the old one.
|
||||
const wideId = history.change(doc => insertRow(doc, 99, 's2'));
|
||||
assert.equal(locate(history.document.blocks, wideId).block.children.length, 6);
|
||||
const narrowId = history.change(doc => insertRow(doc, 0, 's2'));
|
||||
assert.equal(locate(history.document.blocks, narrowId).block.children.length, 2, 'An unusable number falls back to two.');
|
||||
|
||||
// Each insertion is one history entry.
|
||||
for (let i = 0; i < 4; i++) history.undo();
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
Reference in new issue
Block a user