Direktbearbeitung, Kontrastwarnung und Gestaltungsvorlagen

Inline-Textbearbeitung ohne Skript in der Vorschau: der Iframe führt
weiterhin kein eigenes JavaScript aus, der Editor greift in sein
Dokument und setzt das Element auf contenteditable. Während der
Bearbeitung wird die Vorschau nicht neu geladen, sonst wäre der
Cursor weg; Eingaben werden zu einem Undo-Schritt zusammengefasst.
Neben dem Doppelklick startet eine Schaltfläche dasselbe ohne Maus.

Kontrast wird nach WCAG 2.1 berechnet und als Warnung in drei Stufen
gemeldet, nicht als Verbot - der Plan verlangt Prüfung, nicht
Bevormundung.

Gestaltungsvorlagen liegen im Theme und gelten seitenübergreifend.
Sie werden mit demselben Compiler geprüft wie ein Blockstil, also
kann über diesen Weg kein Wert hereinkommen, für den ein Block
abgewiesen würde. Dazu Kopieren und Einfügen zwischen Blöcken und
Bildauswahl für Hintergrundbilder über die Mediathek.

Geprüft: 155 PHP-Tests / 579 Assertions unter PHP 8.2.12 und 8.4.22,
15 JavaScript-Modelltests, Lint/Format/Container. Erstmals wurde der
neue Editor im laufenden Betrieb bedient: Direktbearbeitung und
Kontrastwarnung mit echten Werten ausgelöst, Teständerungen
verworfen. Weiterhin keine axe- oder Lighthouse-Messung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 13:20:01 +02:00
1 parent abf0bd2c6b
commit 270cbda9ac
11 files changed
+388 -10

No files matched your search

+10
View File
@@ -246,3 +246,13 @@ Das Stylesheet steht aus Geschwindigkeitsgründen inline und ist **über seinen
**Google Fonts auf ausdrückliche Entscheidung von Umut.** Das bedeutet eine Anfrage an Google bei jedem Seitenaufruf, mit den bekannten Folgen für Datenschutz und Ladezeit – der Entwicklungsplan verlangt eigentlich lokale Schriften. Umgesetzt ist es so, dass nur die tatsächlich benötigten Schnitte geladen werden und die Richtlinie ihre Hosts nur dann öffnet, wenn wirklich eine Google-Schrift eingestellt ist. Der Weg zum Selbsthosten bleibt offen: es wäre ein zusätzlicher Quelltyp neben `system` und `google`.
Die alten Tokenwerte aus v2 bis v9 bleiben gültig und werden weiterhin gerendert. Blöcke, die noch welche tragen, zeigen sie im Editor in einem eingeklappten Bereich „Klassische Werte dieser Seite“, damit ältere Seiten bearbeitbar bleiben, ohne das neue Panel zu überladen.
## ADR 020 – Direktbearbeitung, Kontrastwarnung und Gestaltungsvorlagen (12.09.2026)
**Inline-Textbearbeitung ohne Skript in der Vorschau.** Der Vorschau-Iframe ist mit `sandbox="allow-same-origin"` eingebettet: er führt kein eigenes JavaScript aus, aber der Editor kann sein Dokument erreichen. Die Bearbeitung läuft deshalb vollständig vom Editor aus – das Element wird auf `contenteditable="plaintext-only"` gesetzt, Tastatureingaben gehen direkt ins Modell, und die Vorschau wird währenddessen **nicht** neu geladen, weil das den Cursor zerstören würde. Erst beim Beenden folgt eine einzige Aktualisierung. Damit bleibt es dabei, dass ausgelieferte Seiten kein Editor-JavaScript enthalten.
Welche Blöcke direkt bearbeitbar sind, steht in einer Tabelle: Überschrift und Text am Block selbst, dazu das Zitat, der Titel eines Aufruf-Bands, einer Symbolkarte, eines Akkordeon-Eintrags, eines Tabs und die Zahl einer Kennzahl. Bei einspaltigen Feldern beendet Enter die Bearbeitung, bei mehrzeiligen nicht. Eingaben werden über einen Merge-Schlüssel zu einem einzigen Undo-Schritt zusammengefasst, wie das Tippen im Eigenschaftenpanel. Neben dem Doppelklick gibt es eine Schaltfläche im Panel, damit der Ablauf auch ohne Maus erreichbar ist.
**Kontrast ist eine Warnung, kein Verbot.** Der Plan verlangt eine Kontrastprüfung, nicht Bevormundung. Das Panel berechnet das Verhältnis zwischen wirksamer Text- und Hintergrundfarbe nach WCAG 2.1 – Palettenverweise werden dafür aufgelöst – und meldet drei Stufen: ab 4,5:1 in Ordnung, ab 3:1 nur für großen Text, darunter zu wenig. Gespeichert wird trotzdem, was eingestellt wurde.
**Gestaltungsvorlagen liegen im Theme**, nicht im Dokument, denn sie gelten seitenübergreifend. Eine Vorlage wird mit demselben Compiler geprüft wie ein Blockstil, sodass über diesen Weg kein Wert hereinkommt, für den ein Block abgewiesen würde. Das Speichern der Palette lässt die Vorlagen unberührt und umgekehrt. Zusätzlich lässt sich eine Gestaltung innerhalb der Sitzung von Block zu Block kopieren; beides ersetzt die Gestaltung des Ziels vollständig, statt sie zu vermischen – ein halb übernommener Look wäre schwerer zu verstehen als ein ganzer.
+8
View File
@@ -1,5 +1,13 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: Direktbearbeitung, Kontrastwarnung, Vorlagen, 12.09.2026
- **155 PHP-Tests / 579 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **15 JavaScript-Modelltests**.
- **Erstmals wurde der neue Editor im laufenden Betrieb bedient**, nicht nur sein Aufbau abgefragt. Geprüft: ein Doppelklick auf die Hauptüberschrift setzt das Element auf `contenteditable="plaintext-only"`, eine Texteingabe erreicht das Modell (der Speicherstatus wechselt auf „Ungespeicherte Änderungen“ und der Strukturbaum zeigt den neuen Text), und das Verlassen entfernt den Bearbeitungszustand wieder. Die Änderungen wurden anschließend verworfen; es wurde nichts gespeichert und keine echte Seite verändert.
- Die Kontrastwarnung wurde mit echten Werten ausgelöst: `#cccccc` auf `#ffffff` ergibt „Kontrast 1.6:1 – zu wenig“ in der roten Stufe. Die Berechnung folgt der relativen Leuchtdichte aus WCAG 2.1 und löst Palettenverweise vorher auf.
- Gestaltungsvorlagen über HTTP geprüft: anonymer Zugriff führt zur Anmeldung, das CSRF-Token ist Pflicht, ein Stil mit eingeschleuster Deklaration (`32px;position:fixed`) und ein leerer Name werden mit HTTP 422 abgewiesen, ohne etwas zu schreiben; Speichern und Entfernen funktionieren. Damit kann über den Umweg Vorlage kein Wert in ein Dokument gelangen, für den ein Block abgewiesen würde.
- **Weiterhin keine axe- und keine Lighthouse-Messung** und keine Erprobung mit einem Screenreader. Bei der Direktbearbeitung ist das ein besonderer Vorbehalt: ob der Fokus sinnvoll wandert und der Wechsel in den Bearbeitungszustand angekündigt wird, ist nicht nachgewiesen – geprüft ist nur, dass die Mechanik funktioniert.
## Ergänzung: freie Gestaltung, 12.09.2026
- **154 PHP-Tests / 563 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **15 JavaScript-Modelltests**.
+11 -4
View File
@@ -1,6 +1,6 @@
# Projektstatus
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Der Editor gestaltet jetzt 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. Arbeitstitel Kant CMS. **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,10 @@ 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.
- **Direktbearbeitung in der Vorschau:** Doppelklick auf Überschrift, Text, Zitat, Aufruf-Band, Symbolkarte, Akkordeon-Eintrag, Tab-Titel oder Kennzahl bearbeitet den Text an Ort und Stelle; Escape beendet, Eingaben werden zu einem Undo-Schritt zusammengefasst. Die Vorschau führt dabei weiterhin kein eigenes JavaScript aus – der Editor greift in ihr Dokument. Eine Schaltfläche im Panel startet dasselbe ohne Maus.
- **Kontrastwarnung** im Designpanel: Verhältnis zwischen wirksamer Text- und Hintergrundfarbe nach WCAG 2.1 mit drei Stufen. Eine Warnung, kein Verbot.
- **Gestaltungsvorlagen** im Theme: eine Gestaltung unter einem Namen speichern und auf jedem Block jeder Seite anwenden, dazu Kopieren und Einfügen zwischen Blöcken innerhalb der Sitzung. Vorlagen werden mit demselben Compiler geprüft wie Blockstile.
- **Hintergrundbilder** werden über die Mediathek gewählt statt über eine eingetippte ID.
- **Freie Gestaltung (Dokumentschema v10).** `StyleSchema` erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung und Grenzen; Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition. Rund fünfzig Eigenschaften in sieben Gruppen: Typografie (Schriftart, Größe, Stärke, Lage, Zeilenhöhe, Buchstaben- und Wortabstand, Ausrichtung, Schreibweise, Textlinie, Farbe), Hintergrund (Farbe, Verlauf, Bild mit Größe/Ausschnitt/Wiederholung/Parallax, Überlagerung), Abstände je Seite, Größen, Rahmen je Seite und Ecken je Ecke, Layout (Flex, Raster, Verteilung, Abstand, Reihenfolge) und Wirkung (Deckkraft, freier Schatten, Verschieben, Skalieren, Drehen, Übergangsdauer). Werte je Gerät mit Vererbung Desktop → Tablet → Mobil und einzeln zurücksetzbar; farbige, numerische und Bewegungswerte zusätzlich für den Hover-Zustand.
- **Theme unter `/admin/theme`**: frei definierbare Farbpalette (bis 40 eigene Farben, im Editor als `@schlüssel`), Schriften aus Systemschrift oder Google Fonts mit Auswahl der Schnitte, und die Grundtypografie aus Schrift, Grundgröße, Zeilenhöhe, Überschriftenskala, Inhaltsbreite sowie Text-, Hintergrund- und Linkfarbe. Eine Palettenfarbe zu ändern wirkt überall, wo sie verwendet wird.
- Der Server erzeugt pro Seite ein blockgenaues Stylesheet mit Media Queries und `:hover,:focus-within`. Es steht inline und ist **über seinen eigenen SHA-256-Hash** erlaubt, nicht über `unsafe-inline`. **Google Fonts auf ausdrückliche Nutzerentscheidung** – das bedeutet eine Anfrage an Google je Seitenaufruf; die Richtlinie öffnet die Hosts nur, wenn tatsächlich eine Google-Schrift eingestellt ist.
@@ -40,8 +44,10 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Geprüft
- 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests. 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.
- 155 PHP-Tests / 579 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests. 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.
- **Im laufenden Editor durchgespielt** (Nutzerwunsch, erstmals für den neuen Editor): Doppelklick setzt das Element auf `contenteditable="plaintext-only"`, eine Eingabe landet im Modell (Speicherstatus wechselt auf „Ungespeicherte Änderungen“, der Strukturbaum zeigt den neuen Text), und das Verlassen beendet die Bearbeitung sauber. Die Kontrastwarnung meldete bei `#cccccc` auf `#ffffff` korrekt „Kontrast 1.6:1 – zu wenig“. Die Schaltflächen zum Kopieren und Einfügen erscheinen mit richtigem Aktivierungszustand. Die Teständerungen wurden verworfen und nicht gespeichert.
- Gestaltungsvorlagen über HTTP geprüft: anonymer Zugriff führt zur Anmeldung, CSRF-Token ist Pflicht, ein Stil mit eingeschleuster Deklaration und ein leerer Name werden mit HTTP 422 abgewiesen ohne zu schreiben, Speichern und Entfernen funktionieren, und das Designpanel erhält Vokabular, Palette und Vorlagen aus einer Quelle.
- Freie Gestaltung geprüft: ein v10-Dokument mit Farben, Abständen je Seite, Ecken je Ecke, Schatten, Hover und eigenem Mobilwert wird unverändert gespeichert und zu blockgenauem CSS samt Media Query und Hover-Regel. **Vierzehn abgewiesene Eingaben** schreiben nichts: unbekannte Eigenschaft, unbekanntes Gerät, Farbe als Wort, Farbe als Funktion, Länge ohne Einheit, Länge als `calc()`, ein eingeschleustes `;position:fixed`, Auswahlwert außerhalb der Liste, Zahl außerhalb des Bereichs, Seitenwert als Einzelwert, unbekannte Seite, Hover auf einer Eigenschaft ohne Hover, Schriftschlüssel mit Anführungszeichen und Hintergrundbild als Pfad. Zusätzlich geprüft, dass freie Stile Schema v10 verlangen.
- Sicherheitsrichtlinie geprüft: das erzeugte Stylesheet wird über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt nicht vor und Skripte bleiben aus. Ohne Google-Schrift wird kein fremder Host erlaubt und keiner kontaktiert; mit Google-Schrift erscheinen `fonts.googleapis.com` in `style-src` und `fonts.gstatic.com` in `font-src`.
- Theme geprüft: Palettenfarben werden zu CSS-Variablen und normalisiert, die Schriftvariable enthält Familie und Ersatz, Grundgröße, Zeilenhöhe und Inhaltsbreite wirken auf jeder Seite. **Acht ungültige Themes** schreiben nichts: Farbe als Wort, doppelter Schlüssel, Schriftname als Stapel, Google-Schrift ohne Namen, Grundfarbe ohne Paletteneintrag, Grundgröße ohne Einheit, Zeilenhöhe außerhalb des Bereichs und leere Palette.
@@ -73,7 +79,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
- **Der neue Editor ist nicht im Browser durchgespielt worden.** Aufbau und Datenfluss des Designpanels wurden im laufenden Editor über das DOM geprüft (sieben Gruppen, 58 Felder, 11 Farbwähler, 12 Regler), aber kein Gestaltungsvorgang von Hand ausgeführt und keine Barrierefreiheitsprüfung gemacht. Ebenfalls offen: Bildauswahl für Hintergrundbilder geschieht noch über die Mediathek-ID statt über die Bildauswahl, es gibt keine Kontrastwarnung, keine Gestaltungsvorlagen zum Wiederverwenden und kein Übertragen der Gestaltung per Oberfläche (die Modellfunktion dafür besteht bereits).
- Der neue Editor ist über das DOM und über gezielte Abläufe geprüft, aber **nach wie vor ohne axe- und Lighthouse-Messung** und ohne Erprobung mit einem Screenreader. Bei der Direktbearbeitung wiegt das besonders: dass der Fokus sinnvoll wandert und der Zustandswechsel angekündigt wird, ist nicht nachgewiesen.
- Gestaltungsvorlagen lassen sich in der Oberfläche nur anlegen und anwenden, nicht umbenennen; das Entfernen geht bisher nur über die Schnittstelle. Es gibt keine Vorschau einer Vorlage.
- SEO-Titel und -Beschreibung sind weiterhin die Dokumentfelder; getrennte SEO-Titel gibt es nicht. Das Vorschaubild wird über seine Mediathek-ID eingetragen und noch nicht als `og:image` aufgelöst. Weiterleitungen werden nur bei genauer Übereinstimmung des Pfades gefunden, ohne Muster und ohne Berücksichtigung von Abfrageparametern; Seiten umbenennen erzeugt noch keine automatische Weiterleitung (Paket F).
- **Dateiuploads in Formularen fehlen** – Anhänge brauchen einen nicht öffentlichen Ablageort mit Zugriffsschutz und eine eigene Typprüfung; Begründung in ADR 017. Ebenfalls offen: Formulare lassen sich nicht löschen oder umbenennen, Einsendungen nicht exportieren oder löschen, und es gibt keine Aufbewahrungsfrist. Der tatsächliche E-Mail-Versand ist nie ausgeführt worden: die Tests nutzen einen Transport, der nichts sendet.
- Das Tab-Skript ist nicht im Browser geprüft: Pfeiltastenbedienung, `aria-selected`, das Verstecken der Panels und die Auswahl über ein Sprungziel sind nur im Code umgesetzt, nicht ausgeführt. Geprüft ist bisher ausschließlich die serverseitig erzeugte Auszeichnung und die Sicherheitsrichtlinie.
@@ -90,7 +97,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Nächster konkreter Schritt
Den neuen Editor im Browser durchspielen und nachschärfen: Bildauswahl für Hintergrundbilder, Kontrastwarnung, Gestaltungsvorlagen und Übertragen der Gestaltung, dazu Inline-Textbearbeitung. Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
Den Editor mit axe und einem Screenreader prüfen, Gestaltungsvorlagen in der Oberfläche verwalten und die Direktbearbeitung auf weitere Textstellen ausdehnen. Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
+11
View File
@@ -59,3 +59,14 @@
.theme-form .weights-cell{white-space:nowrap}
.theme-form .weights-cell label{display:inline-flex;align-items:center;gap:3px;margin:0 8px 0 0;font-size:10px;font-weight:400}
.theme-form .weights-cell input{width:14px;min-height:14px}
.contrast-note{margin:16px 0 0;padding:10px 12px;border-radius:7px;font-size:11px;line-height:1.5;font-weight:600}
.contrast-ok{background:#edf3dc;color:#315b49;border:1px solid #c5d89a}
.contrast-warn{background:#fff6e2;color:#78551d;border:1px solid #e6cf9c}
.contrast-bad{background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7}
.media-control{display:grid;gap:8px}
.media-chosen{margin:0;font-size:11px;color:var(--muted)}
.design-reuse{display:grid;gap:8px;margin-top:24px;padding-top:18px;border-top:1px solid var(--line)}
.design-reuse button,.design-reuse select{width:100%}
#preset-dialog label{margin-top:18px}
#preset-dialog .field-error{margin:10px 0 0;font-size:12px;color:#8b2f22;font-weight:600}
+116 -2
View File
@@ -6,6 +6,7 @@
* appear here without touching this file.
*/
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
import { openMedia } from './media.js';
export const GROUPS = {
text: 'Typografie',
@@ -26,6 +27,21 @@ function splitLength(value) {
return { number: value === '0' ? '0' : '', unit: 'px' };
}
/** Relative luminance per WCAG 2.1, from an #rrggbb value. */
function luminance(hex) {
const channel = index => {
const value = parseInt(hex.slice(1 + index * 2, 3 + index * 2), 16) / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
}
export function contrastRatio(foreground, background) {
const light = Math.max(luminance(foreground), luminance(background));
const dark = Math.min(luminance(foreground), luminance(background));
return (light + 0.05) / (dark + 0.05);
}
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
@@ -33,8 +49,27 @@ function element(tag, className, text) {
return node;
}
export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSelection, getHistory }) {
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, getSelection, getHistory }) {
const change = mutate => { getHistory().change(mutate); onChange(); };
// Lives as long as the editor session, so a look can travel between blocks and pages.
let clipboard = null;
/** Replaces the styling of a block with a copy, or removes it when nothing is given. */
const applyStyle = (doc, id, style) => {
for (const device of Object.keys(devices)) resetStyleDevice(doc, id, device);
if (!style) return;
for (const [device, values] of Object.entries(style)) {
for (const [key, value] of Object.entries(values)) {
if (key === 'hover') {
for (const [hoverKey, hoverValue] of Object.entries(value)) setStyle(doc, id, device, hoverKey, hoverValue, { hover: true });
} else if (value && typeof value === 'object') {
for (const [side, single] of Object.entries(value)) setStyle(doc, id, device, key, single, { side });
} else {
setStyle(doc, id, device, key, value);
}
}
}
};
/** One labelled row with a reset button that appears as soon as this device has an own value. */
const row = (id, labelText, control, { own, inherited, onReset }) => {
@@ -174,6 +209,45 @@ export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSe
return wrap;
};
/** Resolves "@key" to the palette value so a contrast can actually be computed. */
const resolveColor = value => {
if (typeof value !== 'string') return null;
if (/^#[0-9a-fA-F]{6}$/.test(value)) return value.toLowerCase();
if (value.startsWith('@')) {
const entry = schema.colors.find(color => color.key === value.slice(1));
return entry && /^#[0-9a-fA-F]{6}$/.test(entry.value) ? entry.value.toLowerCase() : null;
}
return null;
};
/** A warning, never a prohibition: the plan asks for a contrast check, not for tutelage. */
const contrastNotice = (block, deviceName) => {
const foreground = resolveColor(effectiveStyle(block, deviceName, 'color').value);
const background = resolveColor(effectiveStyle(block, deviceName, 'backgroundColor').value);
if (!foreground || !background) return null;
const ratio = contrastRatio(foreground, background);
const rounded = Math.round(ratio * 10) / 10;
if (ratio >= 4.5) return element('p', 'contrast-note contrast-ok', `Kontrast ${rounded}:1 – erfüllt AA für jede Textgröße.`);
if (ratio >= 3) return element('p', 'contrast-note contrast-warn', `Kontrast ${rounded}:1 – reicht nur für großen Text ab 24 px oder 18,7 px fett. Für Fließtext werden 4,5:1 verlangt.`);
return element('p', 'contrast-note contrast-bad', `Kontrast ${rounded}:1 – zu wenig. AA verlangt 4,5:1 für Fließtext und 3:1 für großen Text.`);
};
const mediaControl = (key, value, apply) => {
const wrap = element('div', 'media-control');
const status = element('p', 'media-chosen', value ? `Bild gewählt (${String(value).slice(0, 8)}…)` : 'Kein Bild gewählt');
const choose = element('button', 'secondary', value ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
choose.type = 'button';
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
wrap.append(status, choose);
if (value) {
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
remove.type = 'button';
remove.addEventListener('click', () => apply(''));
wrap.append(remove);
}
return wrap;
};
/** Renders the whole panel for one block into the given container. */
return function render(panel) {
const block = getBlock();
@@ -213,7 +287,8 @@ export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSe
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
else continue; // media properties are set through the media library
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
else continue;
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
@@ -233,10 +308,49 @@ export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSe
}));
}
}
if (group === 'text') {
const notice = contrastNotice(block, deviceName);
if (notice) body.append(notice);
}
details.append(body);
panel.append(details);
}
// ---- reuse: copy between blocks, save as a named preset, apply a preset ----
const reuse = element('div', 'design-reuse');
reuse.append(element('p', 'design-group-label', 'Gestaltung wiederverwenden'));
const copy = element('button', 'secondary', 'Gestaltung kopieren');
copy.type = 'button';
copy.disabled = !block.style;
copy.addEventListener('click', () => { clipboard = structuredClone(block.style ?? null); onChange(); });
const paste = element('button', 'secondary', 'Gestaltung einfügen');
paste.type = 'button';
paste.disabled = clipboard === null;
paste.addEventListener('click', () => change(doc => applyStyle(doc, getSelection(), clipboard)));
reuse.append(copy, paste);
if (schema.presets.length > 0) {
const select = document.createElement('select');
select.id = 'style-preset';
select.setAttribute('aria-label', 'Gespeicherte Gestaltungsvorlage');
select.append(new Option('Vorlage anwenden …', ''));
for (const preset of schema.presets) select.append(new Option(preset.name, preset.key));
select.addEventListener('change', () => {
const preset = schema.presets.find(entry => entry.key === select.value);
select.value = '';
if (preset) change(doc => applyStyle(doc, getSelection(), preset.style));
});
reuse.append(select);
}
const save = element('button', 'secondary', 'Als Vorlage speichern');
save.type = 'button';
save.disabled = !block.style;
save.addEventListener('click', () => onSavePreset(structuredClone(block.style)));
reuse.append(save);
panel.append(reuse);
const actions = element('div', 'design-actions');
const resetDevice = element('button', 'secondary', `${devices[deviceName]} zurücksetzen`);
resetDevice.type = 'button';
+126 -1
View File
@@ -10,7 +10,7 @@ const $ = selector => document.querySelector(selector);
const api = `/admin/api/pages/${encodeURIComponent(root.dataset.pageId)}`;
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel;
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
const collapsed = new Set();
function notify(message = '', recover = false) {
@@ -173,6 +173,17 @@ function renderProperties() {
return;
}
if (['heading', 'text', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
if (INLINE_TEXT[block.type]) {
const inline = document.createElement('button');
inline.className = 'secondary full-width';
inline.type = 'button';
inline.textContent = 'Direkt in der Vorschau bearbeiten';
inline.addEventListener('click', () => startInlineEdit(block.id));
const note = document.createElement('p');
note.className = 'field-help';
note.textContent = 'Oder in der Vorschau doppelt auf den Text klicken. Escape beendet die Bearbeitung.';
panel.append(inline, note);
}
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
if (block.type === 'iconcard') {
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
@@ -303,6 +314,89 @@ function chooseMedia(multiple) {
} });
}
// ---------------------------------------------------------------------------------------------
// Inline text editing.
//
// The preview runs no script of its own (the iframe is sandboxed without allow-scripts), but the
// editor can reach into its document. Editing therefore happens from here: the element is made
// editable, typing goes straight into the model, and the preview is left alone until editing ends
// so the caret is never pulled away mid-sentence.
// ---------------------------------------------------------------------------------------------
/** Block type to the element inside it that carries the text, and the property it belongs to. */
const INLINE_TEXT = {
heading: { selector: null, prop: 'text' },
text: { selector: null, prop: 'text', multiline: true },
quote: { selector: 'blockquote p', prop: 'text', multiline: true },
cta: { selector: 'h2', prop: 'title' },
iconcard: { selector: 'h3', prop: 'title' },
accordionitem: { selector: 'summary', prop: 'title' },
tabitem: { selector: '.tabs-heading', prop: 'title' },
stat: { selector: 'strong', prop: 'value' },
};
let inlineEdit = null;
function inlineTarget(blockId) {
const block = locate(history.document.blocks, blockId)?.block;
const rule = block && INLINE_TEXT[block.type];
if (!rule) return null;
const root = $('#preview').contentDocument?.getElementById(blockId);
if (!root) return null;
const element = rule.selector ? root.querySelector(rule.selector) : root;
return element ? { block, rule, element } : null;
}
function startInlineEdit(blockId) {
if (inlineEdit?.blockId === blockId) return;
stopInlineEdit();
const target = inlineTarget(blockId);
if (!target || busy) return;
const { rule, element } = target;
element.setAttribute('contenteditable', 'plaintext-only');
element.spellcheck = true;
element.classList.add('editor-inline');
element.focus();
// Put the caret at the end instead of selecting everything, so typing appends.
const range = element.ownerDocument.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = element.ownerDocument.getSelection();
selection.removeAllRanges();
selection.addRange(range);
const onInput = () => {
const value = element.innerText.replace(/\u00a0/g, ' ').replace(/\n+$/, '');
history.change(doc => {
const current = locate(doc.blocks, blockId)?.block;
if (current) current.props[rule.prop] = rule.multiline ? value : value.replace(/\s*\n+\s*/g, ' ');
}, `inline-${blockId}-${rule.prop}`);
controls();
};
const onKey = event => {
if (event.key === 'Escape') { event.preventDefault(); stopInlineEdit(); }
if (event.key === 'Enter' && !rule.multiline) { event.preventDefault(); stopInlineEdit(); }
};
element.addEventListener('input', onInput);
element.addEventListener('keydown', onKey);
element.addEventListener('blur', stopInlineEdit, { once: true });
inlineEdit = { blockId, element, onInput, onKey };
$('#preview-status').textContent = 'Direktbearbeitung · Escape beendet sie, Änderungen stehen sofort im Verlauf.';
}
function stopInlineEdit() {
if (!inlineEdit) return;
const { element, onInput, onKey } = inlineEdit;
inlineEdit = null;
element.removeEventListener('input', onInput);
element.removeEventListener('keydown', onKey);
element.removeAttribute('contenteditable');
element.classList.remove('editor-inline');
history.mergeKey = null;
// One refresh at the end, so the stored document and the preview agree again.
renderTree(); renderProperties(); controls(); schedulePreview();
}
function resizePreview() {
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
const width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
@@ -316,6 +410,8 @@ function resizePreview() {
}
function schedulePreview() {
// Replacing the iframe content would destroy the caret, so editing wins until it ends.
if (inlineEdit) return;
clearTimeout(previewTimer);
// Invalidate an in-flight response immediately, including during the debounce window.
previewSequence++;
@@ -338,9 +434,16 @@ async function updatePreview() {
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
doc.addEventListener('click', event => {
event.preventDefault();
if (inlineEdit) return;
const element = event.target.closest('[id]');
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
});
doc.addEventListener('dblclick', event => {
const element = event.target.closest('[id]');
if (!element) return;
const block = locate(history.document.blocks, element.id)?.block;
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
});
doc.addEventListener('submit', event => event.preventDefault());
frame.contentWindow.scrollTo(0, top); highlight();
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
@@ -365,6 +468,7 @@ async function loadDesignSchema() {
designPanel = createDesignPanel({
schema: designSchema,
onChange: () => { controls(); renderTree(); schedulePreview(); renderProperties(); },
onSavePreset: style => { pendingPreset = style; $('#preset-error').hidden = true; $('#preset-name').value = ''; $('#preset-dialog').showModal(); $('#preset-name').focus(); },
getBlock: () => locate(history.document.blocks, selected)?.block,
getDevice: () => device,
getSelection: () => selected,
@@ -465,6 +569,27 @@ $('#save').addEventListener('click', () => save());
$('#publish')?.addEventListener('click', () => $('#publish-dialog').showModal());
$('#cancel-publish').addEventListener('click', () => $('#publish-dialog').close());
$('#confirm-publish').addEventListener('click', () => { $('#publish-dialog').close(); save(true); });
$('#cancel-preset').addEventListener('click', () => $('#preset-dialog').close());
$('#confirm-preset').addEventListener('click', async () => {
const name = $('#preset-name').value.trim();
const error = $('#preset-error');
try {
const response = await fetch('/admin/api/style-presets', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': designSchema.csrfToken },
body: JSON.stringify({ name, style: pendingPreset }),
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || 'Die Vorlage konnte nicht gespeichert werden.');
// Rebuild the panel so the new preset is immediately offered.
designSchema.presets = body.presets;
$('#preset-dialog').close();
renderProperties();
} catch (problem) {
error.textContent = problem.message;
error.hidden = false;
}
});
$('#reload').addEventListener('click', () => $('#reload-dialog').showModal());
$('#cancel-reload').addEventListener('click', () => $('#reload-dialog').close());
$('#confirm-reload').addEventListener('click', () => { $('#reload-dialog').close(); load(); });
+3
View File
@@ -4,3 +4,6 @@
/* Placeholder for a block whose plugin is missing. Editor preview only; the public page renders
nothing for it, so this style never reaches a visitor. */
.block-unavailable{margin:12px auto;max-width:640px;padding:14px 18px;border:2px dashed #b08a3c;border-radius:8px;background:#fdf6e6;color:#6c5220;font:600 13px/1.6 system-ui,sans-serif}
/* Inline editing. Only ever active in the editor preview, never on a published page. */
[contenteditable].editor-inline{outline:3px solid #b55b12;outline-offset:4px;border-radius:3px;cursor:text;background:#fffdf7}
+43 -2
View File
@@ -14,6 +14,7 @@ final class Theme
{
public const MAX_COLORS = 40;
public const MAX_FONTS = 8;
public const MAX_PRESETS = 40;
/** Weights Google serves for most families; also the range a designer realistically needs. */
public const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
@@ -57,10 +58,15 @@ final class Theme
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
return self::DEFAULTS;
return [...self::DEFAULTS, 'presets' => []];
}
return ['colors' => $stored['colors'], 'fonts' => $stored['fonts'], 'base' => [...self::DEFAULTS['base'], ...$stored['base']]];
return [
'colors' => $stored['colors'],
'fonts' => $stored['fonts'],
'base' => [...self::DEFAULTS['base'], ...$stored['base']],
'presets' => is_array($stored['presets'] ?? null) ? $stored['presets'] : [],
];
}
public function save(array $colors, array $fonts, array $base): void
@@ -69,6 +75,8 @@ final class Theme
'colors' => $this->colors($colors),
'fonts' => $this->fonts($fonts),
'base' => $this->base($base),
// Saving the palette must not drop the presets an editor has collected.
'presets' => $this->load()['presets'],
];
$keys = array_column($theme['colors'], 'key');
foreach (['color', 'backgroundColor', 'linkColor'] as $field) {
@@ -198,6 +206,39 @@ final class Theme
return $base;
}
/**
* Stores a reusable style under a name, or removes it when the style is null. The style is
* validated with the same compiler the documents use, so a preset can never carry a value a
* block would be refused for.
*/
public function savePreset(string $name, ?array $style, StyleCompiler $compiler): void
{
$name = trim($name);
if ('' === $name || mb_strlen($name) > 60) {
throw new InvalidPage('Eine Vorlage braucht einen Namen mit höchstens 60 Zeichen.');
}
$key = trim(preg_replace('/[^a-z0-9]+/', '-', mb_strtolower($name, 'UTF-8')) ?? '', '-');
if ('' === $key) {
throw new InvalidPage('Aus diesem Namen lässt sich kein Schlüssel bilden.');
}
if (null !== $style) {
$compiler->validate($style);
}
$theme = $this->load();
$presets = array_values(array_filter($theme['presets'], static fn (array $p): bool => $p['key'] !== $key));
if (null !== $style) {
if (count($presets) >= self::MAX_PRESETS) {
throw new InvalidPage(sprintf('Mehr als %d Vorlagen sind nicht vorgesehen.', self::MAX_PRESETS));
}
$presets[] = ['key' => $key, 'name' => $name, 'style' => $style];
}
usort($presets, static fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode([...$theme, 'presets' => $presets], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
}
/** The stylesheet every page carries: palette variables, font variables and base typography. */
public function css(array $theme): string
{
+23
View File
@@ -83,9 +83,32 @@ final class EditorController extends AbstractController
'hoverable' => StyleSchema::hoverable(),
'colors' => $theme['colors'],
'fonts' => $theme['fonts'],
'presets' => $theme['presets'],
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
]);
}
/** Saves or removes a reusable style, so a look can be applied to other blocks and pages. */
#[Route('/api/style-presets', name: 'admin_style_preset', methods: ['POST'])]
public function stylePreset(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
}
try {
$body = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
$style = $body['style'] ?? null;
$this->theme->savePreset((string) ($body['name'] ?? ''), is_array($style) ? $style : null, $this->styles);
} catch (InvalidPage|InvalidDocument $e) {
return $this->json(['error' => $e->getMessage()], 422);
} catch (\JsonException) {
return $this->json(['error' => 'Die Anfrage ist unvollständig.'], 422);
}
return $this->json(['presets' => $this->theme->load()['presets']]);
}
#[Route('/pages/{id}/meta', name: 'admin_post_meta', methods: ['POST'])]
public function metadata(Request $request, string $id): Response
{
+1
View File
@@ -37,6 +37,7 @@
</main>
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
<dialog id="preset-dialog" aria-labelledby="preset-title"><h2 id="preset-title">Gestaltung als Vorlage speichern</h2><p>Die Vorlage steht danach für jeden Block auf jeder Seite zur Verfügung.</p><label for="preset-name">Name der Vorlage</label><input id="preset-name" maxlength="60" placeholder="Heller Abschnitt"><p id="preset-error" class="field-error" hidden></p><div class="dialog-actions"><button id="cancel-preset" class="secondary">Abbrechen</button><button id="confirm-preset" class="primary">Vorlage speichern</button></div></dialog>
<dialog id="reload-dialog" aria-labelledby="reload-title"><h2 id="reload-title">Serverstand laden?</h2><p>Lokale Änderungen und dein Undo-Verlauf werden ersetzt. Sichere deine Änderungen vorher als JSON.</p><div class="dialog-actions"><button id="cancel-reload" class="secondary">Abbrechen</button><button id="confirm-reload" class="primary">Serverstand übernehmen</button></div></dialog>
</div>
{% endblock %}
+36 -1
View File
@@ -122,7 +122,7 @@ final class EditorSecurityTest extends WebTestCase
$this->post($client, $action, []);
self::assertResponseRedirects('http://localhost/admin/login');
}
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]], ['/admin/menus/header', ['name' => 'X', 'expectedVersion' => 0]]] as [$url, $body]) {
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]], ['/admin/menus/header', ['name' => 'X', 'expectedVersion' => 0]], ['/admin/api/style-presets', ['name' => 'X']]] as [$url, $body]) {
$client->request('POST', $url, $body);
self::assertResponseRedirects('http://localhost/admin/login');
}
@@ -403,6 +403,41 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame('Kontakt', $this->db->fetchOne('SELECT name FROM form'));
}
public function testStylePresetsNeedAnEditorRoleACsrfTokenAndAValidStyle(): void
{
$client = $this->login('editor');
$client->request('GET', '/admin/api/design');
self::assertResponseIsSuccessful();
$design = json_decode($client->getResponse()->getContent(), true);
// The editor receives the vocabulary, the palette and the presets from one place.
self::assertArrayHasKey('fontSize', $design['properties']);
self::assertSame([], $design['presets']);
$token = $design['csrfToken'];
$post = function (array $body, string $csrf) use ($client): void {
$client->request('POST', '/admin/api/style-presets', server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $csrf], content: json_encode($body));
};
$post(['name' => 'Heller Abschnitt', 'style' => ['desktop' => ['fontSize' => '32px']]], 'wrong');
self::assertResponseStatusCodeSame(403);
// A style the documents would refuse cannot arrive through a preset either.
$post(['name' => 'Boese', 'style' => ['desktop' => ['fontSize' => '32px;position:fixed']]], $token);
self::assertResponseStatusCodeSame(422);
$post(['name' => ' ', 'style' => ['desktop' => ['fontSize' => '32px']]], $token);
self::assertResponseStatusCodeSame(422);
self::assertStringNotContainsString('position:fixed', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
$post(['name' => 'Heller Abschnitt', 'style' => ['desktop' => ['fontSize' => '32px', 'padding' => ['top' => '40px']]]], $token);
self::assertResponseIsSuccessful();
$presets = json_decode($client->getResponse()->getContent(), true)['presets'];
self::assertSame('heller-abschnitt', $presets[0]['key']);
self::assertSame('32px', $presets[0]['style']['desktop']['fontSize']);
// Removing works by sending the same name without a style.
$post(['name' => 'Heller Abschnitt', 'style' => null], $token);
self::assertSame([], json_decode($client->getResponse()->getContent(), true)['presets']);
}
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void
{
$client = $this->login('editor');