Freie Gestaltung statt Tokenlisten (Dokumentschema v10)

Der bisherige Editor bot drei Farben, keine Schriftwahl und elf
Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit
begründet - der Schluss war falsch: die Alternative zu beliebigem
CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes
Designmodell.

StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung
und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe
Definition, also kann der Editor nichts anbieten, was der Server
ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je
Gerät und für den Hover-Zustand, mit Vererbung und einzelnem
Zurücksetzen.

Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text;
zusammengesetzte Deklarationen baut der Compiler selbst. Der Server
erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen
eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline.

Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften
aus Systemschrift oder Google Fonts, Grundtypografie mit modularer
Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung;
die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt
ist.

Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter
"Klassische Werte dieser Seite" bearbeitbar.

Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22,
15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur
der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand
ausgeführt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 13:04:54 +02:00
1 parent a40c2bb27e
commit abf0bd2c6b
24 files changed
+1591 -21

No files matched your search

+20
View File
@@ -226,3 +226,23 @@ Weiterleitungen werden vor dem 404 geprüft, aber **nach** der Seitensuche: eine
`llms.txt` ist **standardmäßig aus** und antwortet dann mit 404. Eingeschaltet listet es ausschließlich veröffentlichte, indexierbare Seiten und Beiträge mit Titel, Adresse und Auszug. Es ist kein Rankingfaktor und keine Voraussetzung für irgendein Suchprodukt; es erspart einem Agenten lediglich das Crawlen. Diese Einordnung steht auch in der Oberfläche.
Der Brotkrumen-Block (Schema v9) trägt keine Inhalte: die Kette entsteht aus der gespeicherten Hierarchie, und nicht veröffentlichte Vorfahren fallen heraus. Eine Seite direkt unter der Wurzel gibt gar keine Navigation aus, statt eine einelementige Kette zu zeigen.
## ADR 019 – Freie Gestaltung statt Tokenlisten (Dokumentschema v10, 12.09.2026)
Umut hat den bisherigen Editor als zu eingeschränkt zurückgewiesen, und der Befund war eindeutig: drei Farben, keine Schriftwahl, elf Auswahllisten mit insgesamt 44 festen Werten, Abstände nur oben und unten. Das ist kein visueller Builder.
ADR 008 hatte die Beschränkung mit Sicherheit und Qualität begründet. Der Schluss war falsch: **die Alternative zu beliebigem CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes Designmodell.** Genau das ersetzt sie jetzt.
`StyleSchema` ist die einzige Quelle der Wahrheit: jede Eigenschaft wird dort einmal mit Typ, CSS-Entsprechung und Grenzen erklärt. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition, sodass die drei nicht auseinanderlaufen können – was der Editor anbietet, ist exakt das, was der Server annimmt und ausgibt. Eine neue Eigenschaft entsteht durch einen Eintrag im Schema, ohne Anfassen der Oberfläche.
Werte werden als kurze Zeichenketten gespeichert (`"18px"`, `"#1a2b3c"`, `"flex"`), niemals als CSS-Text. Jeder Typ hat ein verankertes Muster oder eine feste Liste; zusammengesetzte Deklarationen wie `box-shadow`, `transform` und Verläufe baut der Compiler selbst aus einzeln geprüften Teilen. Eine Eingabe wie `18px;position:fixed` scheitert am Muster, bevor sie irgendwo landet.
Gespeichert wird je Block, je Gerät und optional für den Hover-Zustand. Desktop ist die Basis, Tablet und Mobil überschreiben sie; ein geleerter Wert erbt wieder. Der Server erzeugt daraus pro Seite ein blockgenaues Stylesheet mit `#id{…}`, Media Queries und `:hover,:focus-within` – die Fokusvariante ist bewusst dabei, damit Hover-Gestaltung auch mit der Tastatur sichtbar wird.
Das Stylesheet steht aus Geschwindigkeitsgründen inline und ist **über seinen eigenen SHA-256-Hash** erlaubt, nicht über `unsafe-inline`. Jedes andere eingeschleuste Style-Element bleibt blockiert.
`Theme` ist die Grundlage darüber: eine frei definierbare Farbpalette, Schriften und die Grundtypografie. Farben werden als `@schlüssel` referenziert und zu CSS-Variablen; eine Farbe zu ändern wirkt überall, wo sie benutzt wird. Die Überschriftengrößen entstehen aus einer modularen Skala, sodass eine Zahl die ganze Hierarchie verschiebt.
**Google Fonts auf ausdrückliche Entscheidung von Umut.** Das bedeutet eine Anfrage an Google bei jedem Seitenaufruf, mit den bekannten Folgen für Datenschutz und Ladezeit – der Entwicklungsplan verlangt eigentlich lokale Schriften. Umgesetzt ist es so, dass nur die tatsächlich benötigten Schnitte geladen werden und die Richtlinie ihre Hosts nur dann öffnet, wenn wirklich eine Google-Schrift eingestellt ist. Der Weg zum Selbsthosten bleibt offen: es wäre ein zusätzlicher Quelltyp neben `system` und `google`.
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.
+11
View File
@@ -1,5 +1,16 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## 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**.
- Ein v10-Dokument mit Palettenfarbe, eigenem Farbwert, Abständen je Seite, Ecken je Ecke, Schatten, Buchstabenabstand, Hover-Zustand und eigenem Mobilwert wird Byte für Byte gespeichert und zu blockgenauem CSS: Regel für den Block, eigene Regel für `:hover,:focus-within` und eine Media Query für die schmale Breite.
- **Vierzehn abgewiesene Eingaben schreiben nichts.** Darunter gezielt die Ausbruchsversuche: eine Farbe als Funktion (`rgb(1,2,3)`), eine Länge als Ausdruck (`calc(1px + 2px)`), eine angehängte Deklaration (`18px;position:fixed`), ein Schriftschlüssel mit Anführungszeichen und ein Hintergrundbild als Dateipfad. Jeder dieser Werte scheitert am verankerten Muster seines Typs, bevor er gespeichert wird.
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ohne eingestellte Google-Schrift wird kein fremder Host erlaubt und keiner kontaktiert.
- **Acht ungültige Themes** werden abgewiesen, ohne das gespeicherte Theme zu verändern; danach liefert das Theme weiterhin die Voreinstellungen.
- Im Editormodell geprüft: Vererbung, einzelnes Zurücksetzen, Entfernen leerer Reste, Kopieren ohne geteilte Objekte und vollständiges Undo.
- **Im Browser geprüft wurde nur der Aufbau, nicht die Bedienung.** Im laufenden Editor wurde über das DOM bestätigt, dass das Designpanel aus dem Serverschema entsteht: sieben Gruppen, 58 Felder, 11 Farbwähler, 12 Schieberegler, 34 Einheitenauswahlen. **Es wurde kein Gestaltungsvorgang von Hand ausgeführt**, keine Tastaturbedienung erprobt und keine axe- oder Lighthouse-Messung gemacht. Für ein Werkzeug dieser Größe ist das ein erheblicher Vorbehalt.
- Die Bildschirmfotos des Vorschaufensters blieben bei der Editoransicht unbrauchbar (zu schmale Fläche, zeitweise leere Bilder). Die Beurteilung stützt sich deshalb auf DOM-Abfragen und die erzeugte Ausgabe, nicht auf Augenschein.
## Ergänzung: SEO und maschinelle Zugänglichkeit, 12.09.2026
- **128 PHP-Tests / 507 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **14 JavaScript-Modelltests**.
+11 -3
View File
@@ -1,6 +1,6 @@
# Projektstatus
Stand: 12.09.2026. Arbeitstitel Kant CMS. **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. **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.
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
@@ -25,6 +25,9 @@ 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.
- **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.
- SEO: Indexierungssteuerung und Vorschaubild je Seite, Unternehmensdaten unter `/admin/settings`, `sitemap.xml`, `robots.txt`, Weiterleitungen mit 301 und 410, optionale `llms.txt`, Brotkrumen-Block (Dokumentschema v9) sowie strukturierte Daten für `Organization`/`LocalBusiness`, `Article` und `BreadcrumbList`. Alles Maschinenlesbare wird aus dem veröffentlichten Bestand abgeleitet: eine nicht indexierbare Seite verschwindet gleichzeitig aus der Sitemap und bekommt ein `noindex`-Meta. Ohne gepflegte Unternehmensdaten wird kein leerer Organization-Block ausgegeben. `llms.txt` ist standardmäßig aus.
- Dokumentschema v8: Formulare. Verwaltung unter `/admin/forms` mit Feldtypen Text, E-Mail, Telefon, Mehrzeiliges, Auswahl und Einwilligung, Empfänger, Betreff, Antwortadresse und Erfolgsmeldung. **Der Formularblock im Seiteninhalt enthält nur die Adresse** – Felder und Empfänger liegen in der Datenbank, damit gespeicherter Inhalt nicht ändern kann, wohin eine Anfrage geht. Serverseitige Validierung mit einer Meldung je Feld über `aria-describedby`/`aria-invalid`, Eingaben bleiben bei Ablehnung erhalten (HTTP 422), gültige Absendung per HTTP 303 zurück auf die Seite. Spamschutz ohne Dienst Dritter: Honeypot, signierter Zeitstempel und fünf Absendungen je Absender und Stunde. **Die Absenderadresse wird nie gespeichert**, nur ein Hash; der Zeitpunkt der Einwilligung getrennt. Gespeichert wird vor dem Versand, damit ein kaputter Mailversand keine Anfrage verliert; nicht versendete Einsendungen sind in der Verwaltung gekennzeichnet. Voreingestellter Transport `sendmail`, per `MAILER_DSN` auf SMTP umstellbar. Kein CSRF-Token auf dem öffentlichen Weg, weil dafür eine Sitzung und damit ein Cookie auf jeder öffentlichen Seite nötig wäre; Begründung in ADR 017.
- Dokumentschema v7: Tabs. Der Server liefert Ankerliste und alle Panels mit Überschriften, also vollständigen Inhalt ohne JavaScript; `public/assets/tabs.js` macht daraus ein echtes Tab-Widget mit `tablist`/`tab`/`tabpanel` und Pfeiltastenbedienung. **Die strenge Richtlinie bleibt Voreinstellung:** öffentliche Seiten senden weiterhin `script-src 'none'`, nur eine Seite mit Tab-Block sendet `script-src 'self'`. Inline-Skript ist nie erlaubt. In der Editorvorschau erscheinen Tabs untereinander, weil die Vorschau kein JavaScript ausführt; ein Hinweis im Eigenschaftenpanel sagt das.
@@ -37,8 +40,12 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Geprüft
- 128 PHP-Tests / 507 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 14 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.
- 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.
- 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.
- 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.
- Im Editormodell geprüft: Vererbung vom größeren Gerät, einzelnes Zurücksetzen, das Entfernen leerer Reste, Kopieren der Gestaltung ohne geteilte Objekte und ein vollständiges Undo auf den Ausgangszustand.
- SEO geprüft: die Sitemap ist wohlgeformtes XML und enthält weder eine `noindex`-Seite noch einen Entwurf, dafür Beitragsübersicht und Begriffsarchiv; `robots.txt` verweist auf sie und sperrt `/admin`; eine nicht indexierbare Seite trägt zusätzlich ein `noindex`-Meta. Weiterleitungen antworten vor dem 404 mit 301 beziehungsweise 410, eine vorhandene Seite gewinnt gegen eine gleichnamige Weiterleitung, und Selbstbezug, Kette, Großbuchstaben sowie ein anderer Statuscode werden abgewiesen. Strukturierte Daten entstehen nur aus gepflegten Werten: ohne Angaben kein Block, danach `LocalBusiness` mit Anschrift und entdoppelten Profilen, `Article` mit Autor aus den Beitragsdaten und `BreadcrumbList` aus der Hierarchie. `llms.txt` antwortet ausgeschaltet mit 404 und listet eingeschaltet weder eine `noindex`-Seite noch einen Entwurf. Fünf ungültige Unternehmensangaben schreiben nichts, und in `robots.txt` überleben weder Markup noch eine zweite Sitemap-Zeile.
- Formulare geprüft: Ausgabe ohne Skript, Honeypot außerhalb der Tabreihenfolge, gültige Absendung wird gespeichert und per HTTP 303 zurückgeleitet, Einwilligungszeitpunkt festgehalten, **keine Absenderadresse in der Zeile** sondern ein 64-stelliger Hash, Erfolgsmeldung auf der Zielseite. Fehlerhafte Absendung: HTTP 422, nichts gespeichert, vier Meldungen an den richtigen Feldern mit `aria-describedby` und `aria-invalid`, genau eine Zusammenfassung mit `role=alert`, alle Eingaben erhalten. Vier Spamfälle (gefüllter Honeypot, zu schnell, gefälschte Signatur, zu alter Zeitstempel) werden still verworfen und wie ein Erfolg beantwortet. Das Limit stoppt die sechste Absendung innerhalb einer Stunde. Die Formularverwaltung verlangt Anmeldung, Editorrolle und CSRF-Token und weist eine ungültige Empfängeradresse mit Meldung ab.
- Tabs geprüft: beide Panels samt Inhalt und Überschrift stehen ohne JavaScript im HTML und sind serverseitig nicht versteckt, die Ankerliste verweist auf die Panels, das Skript wird als externe Datei geladen und nie inline. Die Richtlinie sendet `script-src 'self'` ausschließlich auf der Seite mit Tab-Block; eine Seite ohne Tabs und die 404-Seite behalten `script-src 'none'` und laden kein Skript. Ein Tab außerhalb einer Tabgruppe, eine Tabgruppe mit Textkind und Tabs in Schema v6 werden abgewiesen. Im Modell: eine neue Tabgruppe bringt zwei Tabs mit eigenen IDs mit, nimmt nur Tabs auf, und ein Tab lässt sich weder in eine Sektion noch in ein Akkordeon einfügen.
@@ -66,6 +73,7 @@ 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).
- 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.
@@ -82,7 +90,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Nächster konkreter Schritt
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 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.
`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.
+37
View File
@@ -22,3 +22,40 @@
.submission-card{align-items:flex-start}.submission-data{display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:12px 0 0;font-size:12px}.submission-data dt{font-weight:600;color:var(--muted)}.submission-data dd{margin:0;overflow-wrap:anywhere}.menu-table .check-cell{text-align:center}.menu-table .check-cell input{width:18px;min-height:18px}
.redirect-table{margin-top:26px}.redirect-table td{padding:8px 12px 8px 0;font-size:12px}.redirect-table form{margin:0}
/* Design panel: free values instead of fixed lists. */
.design-group{border:1px solid var(--line);border-radius:9px;margin-top:10px;background:#fff}
.design-group>summary{cursor:pointer;padding:12px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
.design-group>summary::after{content:'+';font-size:17px}
.design-group[open]>summary{border-bottom:1px solid var(--line)}
.design-group[open]>summary::after{content:'–'}
.design-group-body{padding:6px 14px 16px}
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
.design-row{margin-top:14px}
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
.design-row-head label{margin:0;font-size:11px}
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
.color-control{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center}
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
.color-control .color-text{font-size:12px;min-height:38px}
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
.length-control input,.length-control select{font-size:12px;min-height:38px}
.length-control.is-inherited input{color:var(--muted)}
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
.number-control input[type=number]{font-size:12px;min-height:38px}
.side-wrap{margin-bottom:6px}
.side-control{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.side-cell label{margin:0 0 4px;font-size:10px;color:var(--muted)}
.side-all{font-size:10px;margin-top:8px;padding:4px 6px}
.design-actions{display:grid;gap:8px;margin-top:22px}
.design-actions button{width:100%}
.theme-form .theme-swatch{width:52px;min-height:38px;padding:2px;cursor:pointer}
.theme-form .weights-cell{white-space:nowrap}
.theme-form .weights-cell label{display:inline-flex;align-items:center;gap:3px;margin:0 8px 0 0;font-size:10px;font-weight:400}
.theme-form .weights-cell input{width:14px;min-height:14px}
+255
View File
@@ -0,0 +1,255 @@
/**
* The design panel.
*
* Every control is generated from the style schema the server sends, so the editor offers exactly
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
* appear here without touching this file.
*/
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
export const GROUPS = {
text: 'Typografie',
background: 'Hintergrund',
spacing: 'Abstand',
size: 'Größe',
border: 'Rahmen',
layout: 'Layout',
effect: 'Wirkung',
};
const UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'ch'];
/** Splits "18.5rem" into its number and its unit; an empty value yields empty parts. */
function splitLength(value) {
const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch)$/.exec(value ?? '');
if (match) return { number: match[1], unit: match[2] };
return { number: value === '0' ? '0' : '', unit: 'px' };
}
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSelection, getHistory }) {
const change = mutate => { getHistory().change(mutate); onChange(); };
/** 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 }) => {
const wrap = element('div', 'design-row' + (own ? ' design-own' : ''));
const head = element('div', 'design-row-head');
const label = element('label', null, labelText);
label.htmlFor = id;
head.append(label);
if (own) {
const reset = element('button', 'text-button design-reset-one', '⟲');
reset.type = 'button';
reset.title = 'Auf geerbten Wert zurücksetzen';
reset.setAttribute('aria-label', `${labelText} zurücksetzen`);
reset.addEventListener('click', onReset);
head.append(reset);
}
wrap.append(head, control);
if (!own && inherited) wrap.append(element('p', 'design-inherited', inherited));
return wrap;
};
const colorControl = (id, value, apply) => {
const wrap = element('div', 'color-control');
const picker = document.createElement('input');
picker.type = 'color';
picker.id = id;
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
const text = document.createElement('input');
text.type = 'text';
text.className = 'color-text';
text.value = value ?? '';
text.placeholder = '#1a2b3c';
text.setAttribute('aria-label', 'Farbwert oder Palettenname');
const palette = document.createElement('select');
palette.className = 'color-palette';
palette.setAttribute('aria-label', 'Farbe aus der Palette');
palette.append(new Option('Palette …', ''));
for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`));
palette.value = (value ?? '').startsWith('@') ? value : '';
picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); });
text.addEventListener('change', () => apply(text.value.trim()));
palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } });
wrap.append(picker, text, palette);
return wrap;
};
const lengthControl = (id, value, apply, placeholder) => {
const wrap = element('div', 'length-control');
const parts = splitLength(value);
const number = document.createElement('input');
number.type = 'number';
number.id = id;
number.step = 'any';
number.value = parts.number;
number.placeholder = placeholder ?? 'erbt';
const unit = document.createElement('select');
unit.setAttribute('aria-label', 'Einheit');
for (const name of UNITS) unit.append(new Option(name, name));
unit.value = parts.unit;
const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`);
number.addEventListener('change', push);
unit.addEventListener('change', () => { if (number.value !== '') push(); });
wrap.append(number, unit);
return wrap;
};
const numberControl = (id, value, property, apply) => {
const wrap = element('div', 'number-control');
const slider = document.createElement('input');
slider.type = 'range';
slider.min = property.min; slider.max = property.max; slider.step = property.step;
slider.value = value ?? property.min;
slider.setAttribute('aria-label', `${property.label} Schieberegler`);
const exact = document.createElement('input');
exact.type = 'number';
exact.id = id;
exact.min = property.min; exact.max = property.max; exact.step = property.step;
exact.value = value ?? '';
exact.placeholder = 'erbt';
slider.addEventListener('input', () => { exact.value = slider.value; });
slider.addEventListener('change', () => apply(slider.value));
exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.value); });
wrap.append(slider, exact);
return wrap;
};
const choiceControl = (id, value, property, apply, inheritedLabel) => {
const select = document.createElement('select');
select.id = id;
select.append(new Option(`Übernehmen: ${inheritedLabel}`, ''));
for (const [key, label] of Object.entries(property.values)) select.append(new Option(label, key));
select.value = value ?? '';
select.addEventListener('change', () => apply(select.value));
return select;
};
const fontControl = (id, value, apply, inheritedLabel) => {
const select = document.createElement('select');
select.id = id;
select.append(new Option(`Übernehmen: ${inheritedLabel}`, ''));
for (const font of schema.fonts) select.append(new Option(`${font.name}${font.family ? ` · ${font.family}` : ''}`, font.key));
select.value = value ?? '';
select.addEventListener('change', () => apply(select.value));
return select;
};
/** Four inputs for padding, margin, border width and corners. */
const sideControl = (key, block, deviceName, property) => {
const grid = element('div', 'side-control');
const parts = property.corners ? schema.corners : schema.sides;
for (const [side, label] of Object.entries(parts)) {
const cell = element('div', 'side-cell');
const id = `style-${key}-${side}`;
const caption = element('label', null, label);
caption.htmlFor = id;
const current = effectiveStyle(block, deviceName, key, side);
const control = lengthControl(id, current.source === deviceName ? current.value : '', value => {
change(doc => setStyle(doc, getSelection(), deviceName, key, value, { side }));
}, current.value ? String(current.value) : 'erbt');
if (current.value && current.source !== deviceName) control.classList.add('is-inherited');
cell.append(caption, control);
grid.append(cell);
}
const all = element('button', 'text-button side-all', 'Alle Seiten gleich setzen');
all.type = 'button';
all.addEventListener('click', () => {
const first = grid.querySelector('input[type=number]');
const unit = grid.querySelector('select');
if (first?.value === '') return;
change(doc => {
for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side });
});
});
const wrap = element('div', 'side-wrap');
wrap.append(grid, all);
return wrap;
};
/** Renders the whole panel for one block into the given container. */
return function render(panel) {
const block = getBlock();
const deviceName = getDevice();
if (!block) return;
const scope = element('p', 'design-scope');
scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`;
panel.append(scope);
panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.'));
for (const [group, groupLabel] of Object.entries(GROUPS)) {
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
if (properties.length === 0) continue;
const details = element('details', 'design-group');
if (group === 'text' || group === 'spacing') details.open = true;
details.append(element('summary', null, groupLabel));
const body = element('div', 'design-group-body');
for (const [key, property] of properties) {
const id = `style-${key}`;
const current = effectiveStyle(block, deviceName, key);
const own = block.style?.[deviceName]?.[key] !== undefined;
const apply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value));
const onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, ''));
const inheritedText = current.value !== undefined && !own
? `Erbt ${describe(property, current.value)} von ${devices[current.source]}`
: 'Theme-Standard';
let control;
if (property.sides || property.corners) {
body.append(element('p', 'design-group-label', property.label), sideControl(key, block, deviceName, property));
continue;
}
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt');
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
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
if (schema.hoverable.includes(key)) {
const hoverId = `${id}-hover`;
const hoverValue = hoverStyle(block, deviceName, key);
const hoverApply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }));
const hoverControl = property.type === 'color'
? colorControl(hoverId, hoverValue ?? '', hoverApply)
: property.type === 'number'
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
body.append(row(hoverId, `${property.label} beim Überfahren`, hoverControl, {
own: hoverValue !== undefined,
inherited: 'Kein eigener Hover-Wert',
onReset: () => hoverApply(''),
}));
}
}
details.append(body);
panel.append(details);
}
const actions = element('div', 'design-actions');
const resetDevice = element('button', 'secondary', `${devices[deviceName]} zurücksetzen`);
resetDevice.type = 'button';
resetDevice.addEventListener('click', () => change(doc => resetStyleDevice(doc, getSelection(), deviceName)));
const resetAll = element('button', 'secondary danger-text', 'Gesamte Gestaltung entfernen');
resetAll.type = 'button';
resetAll.addEventListener('click', () => change(doc => clearStyle(doc, getSelection())));
actions.append(resetDevice, resetAll);
panel.append(actions);
};
function describe(property, value) {
if (property.type === 'choice') return property.values[value] ?? value;
return String(value);
}
}
+91 -2
View File
@@ -99,7 +99,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// Called only inside a history transaction: all other fields and IDs stay untouched.
export function upgradeDocument(document, version = 2) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
document.schemaVersion = Math.max(document.schemaVersion, version);
return document;
}
@@ -116,7 +116,7 @@ export function effectiveDesign(block, device, key) {
}
export function setDesign(document, id, device, key, value) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
@@ -222,3 +222,92 @@ export class EditorHistory {
markSaved(document) { this.saved = JSON.stringify(document); this.mergeKey = null; }
get dirty() { return JSON.stringify(this.document) !== this.saved; }
}
// ---------------------------------------------------------------------------------------------
// Free block styles (document schema v10).
//
// A style is { device: { property: value, hover: { property: value } } }. Desktop is the base,
// tablet and mobile override it. Everything mirrors StyleSchema on the server: the editor can only
// offer what the server accepts, and a value the server would reject never reaches a document.
// ---------------------------------------------------------------------------------------------
/** The value that actually applies on a device, plus the device it was inherited from. */
export function effectiveStyle(block, device, key, side = null) {
let value, source;
for (const name of Object.keys(devices)) {
const stored = block?.style?.[name]?.[key];
const candidate = side === null ? stored : stored?.[side];
if (candidate !== undefined) { value = candidate; source = name; }
if (name === device) break;
}
return { value, source };
}
export function hoverStyle(block, device, key) {
return block?.style?.[device]?.hover?.[key];
}
/** Removes empty objects so an untouched block never carries a style skeleton. */
function prune(block) {
for (const device of Object.keys(block.style ?? {})) {
const values = block.style[device];
for (const key of Object.keys(values)) {
if (values[key] && typeof values[key] === 'object' && Object.keys(values[key]).length === 0) delete values[key];
}
if (Object.keys(values).length === 0) delete block.style[device];
}
if (block.style && Object.keys(block.style).length === 0) delete block.style;
}
/**
* Sets one style value. An empty value removes it, which makes the block fall back to the wider
* device and finally to the theme. Called inside a history transaction like every other change.
*/
export function setStyle(document, id, device, key, value, { side = null, hover = false } = {}) {
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
const empty = value === '' || value === null || value === undefined;
if (!empty) upgradeDocument(document, 10);
block.style ??= {};
block.style[device] ??= {};
let target = block.style[device];
if (hover) { target.hover ??= {}; target = target.hover; }
if (side !== null) {
if (empty) { if (target[key]) delete target[key][side]; }
else { target[key] ??= {}; target[key][side] = value; }
} else if (empty) {
delete target[key];
} else {
target[key] = value;
}
prune(block);
}
/** Drops every style of one device, so it inherits again. */
export function resetStyleDevice(document, id, device) {
const block = locate(document.blocks, id)?.block;
if (!block?.style) return;
delete block.style[device];
prune(block);
}
/** Drops the whole styling of a block. */
export function clearStyle(document, id) {
const block = locate(document.blocks, id)?.block;
if (!block) return;
delete block.style;
}
/** Copies the styling of one block onto another, with fresh objects. */
export function copyStyle(document, fromId, toId) {
const source = locate(document.blocks, fromId)?.block;
const target = locate(document.blocks, toId)?.block;
if (!source || !target) return false;
if (!source.style) { delete target.style; return true; }
upgradeDocument(document, 10);
target.style = structuredClone(source.style);
return true;
}
+40 -2
View File
@@ -1,5 +1,6 @@
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, 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, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { openMedia } from './media.js';
import { createDesignPanel } from './design-panel.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'];
@@ -9,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;
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel;
const collapsed = new Set();
function notify(message = '', recover = false) {
@@ -235,6 +236,24 @@ function renderProperties() {
}
function renderDesign(block, panel) {
if (designSchema && designPanel) {
designPanel(panel);
} else {
panel.append(Object.assign(document.createElement('p'), { className: 'panel-note', textContent: 'Gestaltungsoptionen werden geladen …' }));
}
const hasLegacy = Boolean(block.layout?.design) || Boolean(block.layout?.effects);
if (!hasLegacy) return;
const details = document.createElement('details'); details.className = 'design-group';
const summary = document.createElement('summary'); summary.textContent = 'Klassische Werte dieser Seite';
const body = document.createElement('div'); body.className = 'design-group-body';
details.append(summary, body);
renderLegacyDesign(block, body);
panel.append(details);
}
function renderLegacyDesign(block, panel) {
// Token based values from documents written before schema v10. Shown only while a block
// still carries them, so older pages stay editable without cluttering the new panel.
const hint = document.createElement('p'); hint.className = 'design-scope';
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`;
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
@@ -338,6 +357,24 @@ function changed() {
renderTree(); renderProperties(); controls(); schedulePreview();
}
async function loadDesignSchema() {
try {
const response = await fetch('/admin/api/design', { credentials: 'same-origin' });
if (!response.ok) return;
designSchema = await response.json();
designPanel = createDesignPanel({
schema: designSchema,
onChange: () => { controls(); renderTree(); schedulePreview(); renderProperties(); },
getBlock: () => locate(history.document.blocks, selected)?.block,
getDevice: () => device,
getSelection: () => selected,
getHistory: () => history,
});
} catch {
// The editor stays usable without it; the panel says so.
}
}
async function load() {
busy = true; controls();
try {
@@ -347,6 +384,7 @@ async function load() {
if (!response.ok) throw new Error('Designwerkzeuge konnten nicht geladen werden. Bitte erneut versuchen.');
designTokens = await response.json();
}
if (!designSchema) await loadDesignSchema();
history = new EditorHistory(data.document);
({ version, revisionId, publishedRevisionId, csrfToken } = data);
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
+11 -1
View File
@@ -6,6 +6,10 @@ use App\Domain\InvalidDocument;
final class DocumentValidator
{
public function __construct(private StyleCompiler $styles = new StyleCompiler())
{
}
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
@@ -21,7 +25,7 @@ final class DocumentValidator
public function validate(array $document): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9], true), 'Unsupported document schema.');
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 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.');
@@ -51,6 +55,12 @@ final class DocumentValidator
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
$this->require('tabitem' !== $type || 'tabs' === $parentType, 'A tab belongs into a tab group.');
if (isset($block['style'])) {
// Free styling: any property of StyleSchema, per device, with an optional hover value.
// The old token based layout.design keeps working unchanged next to it.
$this->require($schemaVersion >= 10, 'Free block styles require schema v10.');
$this->styles->validate($block['style']);
}
$props = $block['props'] ?? [];
$layout = $block['layout'] ?? [];
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
+264
View File
@@ -0,0 +1,264 @@
<?php
namespace App\Content;
use App\Domain\InvalidDocument;
/**
* Validates free block styles and compiles them into CSS.
*
* Both directions read StyleSchema, so the editor, the stored document and the rendered page
* always agree. Nothing here ever concatenates user text into CSS: every value is matched against
* an anchored pattern or a fixed list first, and only then placed into a declaration that this
* class writes itself.
*/
final class StyleCompiler
{
private const BREAKPOINTS = ['tablet' => 1024, 'mobile' => 767];
public function __construct(private ?MediaLibrary $media = null)
{
}
/** @throws InvalidDocument */
public function validate(mixed $style): void
{
$this->require(is_array($style), 'Style must be an object.');
$properties = StyleSchema::properties();
$hoverable = StyleSchema::hoverable();
foreach ($style as $device => $values) {
$this->require(isset(StyleSchema::DEVICES[$device]) && is_array($values), 'Unknown style device.');
foreach ($values as $key => $value) {
if ('hover' === $key) {
$this->require(is_array($value), 'Hover styles must be an object.');
foreach ($value as $hoverKey => $hoverValue) {
$this->require(in_array($hoverKey, $hoverable, true), sprintf('The property "%s" has no hover value.', (string) $hoverKey));
$this->value($properties[$hoverKey], $hoverValue, (string) $hoverKey);
}
continue;
}
$this->require(is_string($key) && isset($properties[$key]), sprintf('Unknown style property "%s".', (string) $key));
$this->value($properties[$key], $value, $key);
}
}
}
private function value(array $property, mixed $value, string $key): void
{
if (($property['sides'] ?? false) || ($property['corners'] ?? false)) {
$parts = ($property['corners'] ?? false) ? StyleSchema::corners() : StyleSchema::sides();
$this->require(is_array($value), sprintf('"%s" is given per part.', $key));
foreach ($value as $part => $single) {
$this->require(isset($parts[$part]), sprintf('Unknown part for "%s".', $key));
$this->scalar($property, $single, $key);
}
return;
}
$this->scalar($property, $value, $key);
}
private function scalar(array $property, mixed $value, string $key): void
{
$this->require(is_string($value) || is_int($value) || is_float($value), sprintf('"%s" needs a simple value.', $key));
$text = (string) $value;
match ($property['type']) {
'color' => $this->require(1 === preg_match(StyleSchema::COLOR, $text), sprintf('"%s" is not a colour.', $key)),
'length' => $this->require(1 === preg_match(StyleSchema::LENGTH, $text), sprintf('"%s" is not a length.', $key)),
'length-auto' => $this->require(1 === preg_match(StyleSchema::LENGTH_AUTO, $text), sprintf('"%s" is not a length.', $key)),
'choice' => $this->require(isset($property['values'][$text]), sprintf('"%s" is not an allowed value for %s.', $text, $key)),
'number' => $this->require(
is_numeric($value) && (float) $value >= $property['min'] && (float) $value <= $property['max'],
sprintf('"%s" is outside its range.', $key),
),
'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)),
'media' => $this->require(1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
default => $this->require(false, 'Unknown style type.'),
};
}
/**
* Compiles every styled block of a document into one stylesheet.
*
* @return string CSS, already escaped by construction; empty when nothing is styled
*/
public function compileDocument(array $document): string
{
$css = '';
$walk = function (array $blocks) use (&$walk, &$css): void {
foreach ($blocks as $block) {
if (is_array($block['style'] ?? null) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', (string) ($block['id'] ?? ''))) {
$css .= $this->compileBlock((string) $block['id'], $block['style']);
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $css;
}
private function compileBlock(string $id, array $style): string
{
$css = '';
foreach (['desktop', 'tablet', 'mobile'] as $device) {
$values = $style[$device] ?? null;
if (!is_array($values)) {
continue;
}
$rules = '';
$base = $this->declarations($values);
if ('' !== $base) {
$rules .= sprintf('#%s{%s}', $id, $base);
}
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover']) : '';
if ('' !== $hover) {
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
}
if ('' === $rules) {
continue;
}
// Desktop first, narrower screens override it. Mobile last so it wins on small screens.
$css .= isset(self::BREAKPOINTS[$device])
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules)
: $rules;
}
return $css;
}
/** Turns one validated value set into CSS declarations. */
private function declarations(array $values): string
{
$properties = StyleSchema::properties();
$out = [];
foreach ($values as $key => $value) {
if ('hover' === $key || !isset($properties[$key])) {
continue;
}
$property = $properties[$key];
if (null === $property['css']) {
continue; // Composed below out of several values.
}
if ($property['corners'] ?? false) {
// border-radius becomes border-top-left-radius and friends.
foreach ($value as $corner => $single) {
$out[] = sprintf('border-%s-radius:%s', StyleSchema::cornerCss((string) $corner), $this->css($property, (string) $single));
}
continue;
}
if ($property['sides'] ?? false) {
foreach ($value as $side => $single) {
$out[] = sprintf('%s-%s:%s', $property['css'], $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;
}
}
return implode(';', $out);
}
private function css(array $property, string $value): string
{
if ('color' === $property['type']) {
return str_starts_with($value, '@') ? sprintf('var(--k-%s)', substr($value, 1)) : $value;
}
if ('font' === $property['type']) {
return sprintf('var(--kf-%s)', $value);
}
return $value;
}
/** Overlay, gradient and image share one background-image declaration, layered in that order. */
private function background(array $values): string
{
$layers = [];
$overlay = $values['overlayColor'] ?? null;
if (null !== $overlay) {
$colour = $this->css(['type' => 'color'], (string) $overlay);
$alpha = isset($values['overlayOpacity']) ? (float) $values['overlayOpacity'] : 0.5;
$layers[] = sprintf('linear-gradient(color-mix(in srgb,%s %d%%,transparent),color-mix(in srgb,%s %d%%,transparent))', $colour, (int) round($alpha * 100), $colour, (int) round($alpha * 100));
}
if (isset($values['gradientFrom'], $values['gradientTo'])) {
$layers[] = sprintf(
'linear-gradient(%ddeg,%s,%s)',
(int) ($values['gradientAngle'] ?? 180),
$this->css(['type' => 'color'], (string) $values['gradientFrom']),
$this->css(['type' => 'color'], (string) $values['gradientTo']),
);
}
if (isset($values['backgroundImage']) && null !== $this->media) {
$item = $this->media->find((string) $values['backgroundImage']);
if (null !== $item && [] !== $item['variants']) {
$largest = end($item['variants']);
// The URL comes from the media library, never from the document.
$layers[] = sprintf('url("%s")', $largest['src']);
}
}
return [] === $layers ? '' : 'background-image:'.implode(',', $layers);
}
private function shadow(array $values): string
{
$parts = array_intersect_key($values, array_flip(['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowColor']));
if ([] === $parts) {
return '';
}
return sprintf(
'box-shadow:%s %s %s %s %s',
$values['shadowX'] ?? '0',
$values['shadowY'] ?? '0',
$values['shadowBlur'] ?? '0',
$values['shadowSpread'] ?? '0',
$this->css(['type' => 'color'], (string) ($values['shadowColor'] ?? '#00000026')),
);
}
private function transform(array $values): string
{
$parts = [];
foreach (['translateX' => 'translateX(%s)', 'translateY' => 'translateY(%s)'] as $key => $format) {
if (isset($values[$key])) {
$parts[] = sprintf($format, $values[$key]);
}
}
if (isset($values['scale'])) {
$parts[] = sprintf('scale(%s)', (float) $values['scale']);
}
if (isset($values['rotate'])) {
$parts[] = sprintf('rotate(%ddeg)', (int) $values['rotate']);
}
return [] === $parts ? '' : 'transform:'.implode(' ', $parts);
}
private function grid(array $values): string
{
return isset($values['gridColumns'])
? sprintf('grid-template-columns:repeat(%d,minmax(0,1fr))', max(1, min(12, (int) $values['gridColumns'])))
: '';
}
private function transition(array $values): string
{
return isset($values['transition'])
? sprintf('transition:all %dms ease', max(0, min(2000, (int) $values['transition'])))
: '';
}
private function require(bool $condition, string $message): void
{
if (!$condition) {
throw new InvalidDocument($message);
}
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Content;
/**
* The single source of truth for free block styling.
*
* Every property is declared once here with its type, its CSS counterpart and its limits. The
* validator, the CSS generator and the editor panel all read this definition, so the three can
* never drift apart: what the editor offers is exactly what the server accepts and renders.
*
* Values are stored as short strings ("18px", "#1a2b3c", "flex"), never as CSS text. Each type has
* an anchored pattern or a fixed list, so no declaration can escape its property.
*/
final class StyleSchema
{
public const DEVICES = ['desktop' => 'Desktop', 'tablet' => 'Tablet', 'mobile' => 'Mobil'];
/** A colour is a hex value or "@name", a reference into the site palette. */
public const COLOR = '/^(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|@[a-z][a-z0-9-]{0,31})$/D';
/** A length is a number with a unit, plus the two keywords that behave like one. */
public const LENGTH = '/^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/D';
public const LENGTH_AUTO = '/^(auto|none|0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/D';
/**
* 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.
*
* @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}>
*/
public static function properties(): array
{
return [
// ---- Typografie -------------------------------------------------------------
'fontFamily' => ['group' => 'text', 'type' => 'font', 'css' => 'font-family', 'label' => 'Schriftart'],
'fontSize' => ['group' => 'text', 'type' => 'length', 'css' => 'font-size', 'label' => 'Schriftgröße'],
'fontWeight' => ['group' => 'text', 'type' => 'choice', 'css' => 'font-weight', 'label' => 'Schriftstärke', 'values' => [
'100' => '100 · Thin', '200' => '200 · Extra Light', '300' => '300 · Light', '400' => '400 · Regular',
'500' => '500 · Medium', '600' => '600 · Semibold', '700' => '700 · Bold', '800' => '800 · Extra Bold', '900' => '900 · Black',
]],
'fontStyle' => ['group' => 'text', 'type' => 'choice', 'css' => 'font-style', 'label' => 'Schriftlage', 'values' => ['normal' => 'Normal', 'italic' => 'Kursiv']],
'lineHeight' => ['group' => 'text', 'type' => 'number', 'css' => 'line-height', 'label' => 'Zeilenhöhe', 'min' => 0.7, 'max' => 4, 'step' => 0.05],
'letterSpacing' => ['group' => 'text', 'type' => 'length', 'css' => 'letter-spacing', 'label' => 'Buchstabenabstand'],
'wordSpacing' => ['group' => 'text', 'type' => 'length', 'css' => 'word-spacing', 'label' => 'Wortabstand'],
'textAlign' => ['group' => 'text', 'type' => 'choice', 'css' => 'text-align', 'label' => 'Ausrichtung', 'values' => [
'left' => 'Links', 'center' => 'Mittig', 'right' => 'Rechts', 'justify' => 'Blocksatz',
]],
'textTransform' => ['group' => 'text', 'type' => 'choice', 'css' => 'text-transform', 'label' => 'Schreibweise', 'values' => [
'none' => 'Wie eingegeben', 'uppercase' => 'GROSSBUCHSTABEN', 'lowercase' => 'kleinbuchstaben', 'capitalize' => 'Erster Buchstabe Groß',
]],
'textDecoration' => ['group' => 'text', 'type' => 'choice', 'css' => 'text-decoration-line', 'label' => 'Textlinie', 'values' => [
'none' => 'Keine', 'underline' => 'Unterstrichen', 'line-through' => 'Durchgestrichen',
]],
'color' => ['group' => 'text', 'type' => 'color', 'css' => 'color', 'label' => 'Textfarbe', 'hover' => true],
// ---- Hintergrund ------------------------------------------------------------
'backgroundColor' => ['group' => 'background', 'type' => 'color', 'css' => 'background-color', 'label' => 'Hintergrundfarbe', 'hover' => true],
'gradientFrom' => ['group' => 'background', 'type' => 'color', 'css' => null, 'label' => 'Verlauf von'],
'gradientTo' => ['group' => 'background', 'type' => 'color', 'css' => null, 'label' => 'Verlauf bis'],
'gradientAngle' => ['group' => 'background', 'type' => 'number', 'css' => null, 'label' => 'Verlaufswinkel', 'min' => 0, 'max' => 360, 'step' => 1],
'backgroundImage' => ['group' => 'background', 'type' => 'media', 'css' => null, 'label' => 'Hintergrundbild'],
'backgroundSize' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-size', 'label' => 'Bildgröße', 'values' => [
'cover' => 'Füllend', 'contain' => 'Vollständig sichtbar', 'auto' => 'Originalgröße',
]],
'backgroundPosition' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-position', 'label' => 'Bildausschnitt', 'values' => [
'center' => 'Mittig', 'top' => 'Oben', 'bottom' => 'Unten', 'left' => 'Links', 'right' => 'Rechts',
'top left' => 'Oben links', 'top right' => 'Oben rechts', 'bottom left' => 'Unten links', 'bottom right' => 'Unten rechts',
]],
'backgroundRepeat' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-repeat', 'label' => 'Wiederholung', 'values' => [
'no-repeat' => 'Nicht wiederholen', 'repeat' => 'Kacheln', 'repeat-x' => 'Waagerecht kacheln', 'repeat-y' => 'Senkrecht kacheln',
]],
'backgroundAttachment' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-attachment', 'label' => 'Scrollverhalten', 'values' => [
'scroll' => 'Mitscrollen', 'fixed' => 'Feststehend (Parallax)',
]],
'overlayColor' => ['group' => 'background', 'type' => 'color', 'css' => null, 'label' => 'Überlagerung'],
'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],
// ---- Größe ------------------------------------------------------------------
'width' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'width', 'label' => 'Breite'],
'maxWidth' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'max-width', 'label' => 'Maximale Breite'],
'minWidth' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'min-width', 'label' => 'Mindestbreite'],
'height' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'height', 'label' => 'Höhe'],
'minHeight' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'min-height', 'label' => 'Mindesthöhe'],
'maxHeight' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'max-height', 'label' => 'Maximale Höhe'],
'overflow' => ['group' => 'size', 'type' => 'choice', 'css' => 'overflow', 'label' => 'Überlauf', 'values' => [
'visible' => 'Sichtbar', 'hidden' => 'Abschneiden', 'auto' => 'Scrollen bei Bedarf',
]],
// ---- Rahmen -----------------------------------------------------------------
'borderWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'border-width', 'label' => 'Rahmenstärke', 'sides' => true],
'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],
// ---- Layout -----------------------------------------------------------------
'display' => ['group' => 'layout', 'type' => 'choice', 'css' => 'display', 'label' => 'Anzeige', 'values' => [
'block' => 'Block', 'flex' => 'Flex', 'inline-flex' => 'Inline-Flex', 'grid' => 'Raster', 'inline-block' => 'Inline-Block', 'none' => 'Ausgeblendet',
]],
'flexDirection' => ['group' => 'layout', 'type' => 'choice', 'css' => 'flex-direction', 'label' => 'Flexrichtung', 'values' => [
'row' => 'Nebeneinander', 'row-reverse' => 'Nebeneinander, umgekehrt', 'column' => 'Untereinander', 'column-reverse' => 'Untereinander, umgekehrt',
]],
'flexWrap' => ['group' => 'layout', 'type' => 'choice', 'css' => 'flex-wrap', 'label' => 'Umbruch', 'values' => ['wrap' => 'Umbrechen', 'nowrap' => 'Nicht umbrechen']],
'justifyContent' => ['group' => 'layout', 'type' => 'choice', 'css' => 'justify-content', 'label' => 'Verteilung', 'values' => [
'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'space-between' => 'Außen verteilt', 'space-around' => 'Gleichmäßig', 'space-evenly' => 'Exakt gleich',
]],
'alignItems' => ['group' => 'layout', 'type' => 'choice', 'css' => 'align-items', 'label' => 'Ausrichtung quer', 'values' => [
'stretch' => 'Dehnen', 'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'baseline' => 'Grundlinie',
]],
'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],
// ---- Wirkung ----------------------------------------------------------------
'opacity' => ['group' => 'effect', 'type' => 'number', 'css' => 'opacity', 'label' => 'Deckkraft', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
'shadowX' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten waagerecht'],
'shadowY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten senkrecht'],
'shadowBlur' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten weich'],
'shadowSpread' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten größer'],
'shadowColor' => ['group' => 'effect', 'type' => 'color', 'css' => null, 'label' => 'Schattenfarbe', 'hover' => true],
'translateX' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Verschieben waagerecht', 'hover' => true],
'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],
'transition' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Übergangsdauer (ms)', 'min' => 0, 'max' => 2000, 'step' => 25],
];
}
/** Properties that may also be given a hover value. */
public static function hoverable(): array
{
return array_keys(array_filter(self::properties(), static fn (array $p): bool => $p['hover'] ?? false));
}
public static function sides(): array
{
return ['top' => 'Oben', 'right' => 'Rechts', 'bottom' => 'Unten', 'left' => 'Links'];
}
/** Corners round a box; they are not sides, and their CSS property is spelled differently. */
public static function corners(): array
{
return ['topLeft' => 'Oben links', 'topRight' => 'Oben rechts', 'bottomRight' => 'Unten rechts', 'bottomLeft' => 'Unten links'];
}
/** "topLeft" becomes the CSS fragment "top-left". */
public static function cornerCss(string $corner): string
{
return strtolower(preg_replace('/([A-Z])/', '-$1', $corner) ?? $corner);
}
}
+254
View File
@@ -0,0 +1,254 @@
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* The site wide design system: a free colour palette, font families and the base typography every
* page inherits. This is the "Haupt-Typografie" – set once here, valid everywhere, overridable on
* any block. Stored in the website row that has carried a theme column since the first migration.
*/
final class Theme
{
public const MAX_COLORS = 40;
public const MAX_FONTS = 8;
/** 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];
public const FALLBACKS = [
'sans-serif' => 'Serifenlos',
'serif' => 'Serifen',
'monospace' => 'Feste Breite',
'system-ui' => 'Systemschrift',
];
public const DEFAULTS = [
'colors' => [
['key' => 'paper', 'name' => 'Papier', 'value' => '#f6f7f4'],
['key' => 'ink', 'name' => 'Tinte', 'value' => '#203b34'],
['key' => 'mint', 'name' => 'Mint', 'value' => '#e4efb9'],
['key' => 'accent', 'name' => 'Akzent', 'value' => '#b55b12'],
],
'fonts' => [
['key' => 'body', 'name' => 'Fließtext', 'family' => '', 'source' => 'system', 'weights' => [400, 600], 'fallback' => 'system-ui'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => '', 'source' => 'system', 'weights' => [500, 700], 'fallback' => 'system-ui'],
],
'base' => [
'fontFamily' => 'body',
'headingFontFamily' => 'heading',
'fontSize' => '18px',
'lineHeight' => 1.6,
'color' => '@ink',
'backgroundColor' => '@paper',
'linkColor' => '@accent',
'containerWidth' => '1240px',
'headingScale' => 1.25,
],
];
public function __construct(private Connection $db)
{
}
public function load(): array
{
$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 ['colors' => $stored['colors'], 'fonts' => $stored['fonts'], 'base' => [...self::DEFAULTS['base'], ...$stored['base']]];
}
public function save(array $colors, array $fonts, array $base): void
{
$theme = [
'colors' => $this->colors($colors),
'fonts' => $this->fonts($fonts),
'base' => $this->base($base),
];
$keys = array_column($theme['colors'], 'key');
foreach (['color', 'backgroundColor', 'linkColor'] as $field) {
$value = $theme['base'][$field];
if (str_starts_with($value, '@') && !in_array(substr($value, 1), $keys, true)) {
throw new InvalidPage(sprintf('Die Grundfarbe verweist auf „%s“, das es in der Palette nicht gibt.', substr($value, 1)));
}
}
foreach (['fontFamily', 'headingFontFamily'] as $field) {
if (!in_array($theme['base'][$field], array_column($theme['fonts'], 'key'), true)) {
throw new InvalidPage('Die Grundschrift verweist auf eine Schrift, die es nicht gibt.');
}
}
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
}
private function colors(array $input): array
{
$colors = [];
$seen = [];
foreach ($input as $row) {
$key = strtolower(trim((string) ($row['key'] ?? '')));
$value = trim((string) ($row['value'] ?? ''));
if ('' === $key && '' === $value) {
continue;
}
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,31}$/D', $key) || isset($seen[$key])) {
throw new InvalidPage('Jede Farbe braucht einen eindeutigen Schlüssel aus Kleinbuchstaben, Ziffern und Bindestrich.');
}
if (1 !== preg_match('/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/D', $value)) {
throw new InvalidPage(sprintf('„%s“ ist kein Farbwert wie #1a2b3c.', $value));
}
$seen[$key] = true;
$colors[] = ['key' => $key, 'name' => mb_substr(trim((string) ($row['name'] ?? $key)), 0, 60) ?: $key, 'value' => strtolower($value)];
}
if ([] === $colors) {
throw new InvalidPage('Die Palette braucht mindestens eine Farbe.');
}
if (count($colors) > self::MAX_COLORS) {
throw new InvalidPage(sprintf('Mehr als %d Farben sind nicht vorgesehen.', self::MAX_COLORS));
}
return $colors;
}
private function fonts(array $input): array
{
$fonts = [];
$seen = [];
foreach ($input as $row) {
$key = strtolower(trim((string) ($row['key'] ?? '')));
if ('' === $key) {
continue;
}
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $key) || isset($seen[$key])) {
throw new InvalidPage('Jede Schrift braucht einen eindeutigen Schlüssel aus Kleinbuchstaben, Ziffern und Bindestrich.');
}
$family = trim((string) ($row['family'] ?? ''));
// Only a family name, never a font stack: the fallback is chosen separately, so no
// quoting or comma can travel into the generated CSS.
if ('' !== $family && 1 !== preg_match('/^[A-Za-z0-9][A-Za-z0-9 ]{0,48}$/D', $family)) {
throw new InvalidPage(sprintf('„%s“ ist kein einfacher Schriftname.', $family));
}
$source = (string) ($row['source'] ?? 'system');
if (!in_array($source, ['system', 'google'], true)) {
throw new InvalidPage('Als Quelle sind Systemschrift und Google Fonts vorgesehen.');
}
if ('google' === $source && '' === $family) {
throw new InvalidPage('Für eine Google-Schrift wird ein Name benötigt, zum Beispiel Inter.');
}
$fallback = (string) ($row['fallback'] ?? 'system-ui');
if (!isset(self::FALLBACKS[$fallback])) {
throw new InvalidPage('Unbekannte Ersatzschrift.');
}
$weights = array_values(array_intersect(self::WEIGHTS, array_map('intval', (array) ($row['weights'] ?? [400]))));
$seen[$key] = true;
$fonts[] = [
'key' => $key,
'name' => mb_substr(trim((string) ($row['name'] ?? $key)), 0, 60) ?: $key,
'family' => $family,
'source' => $source,
'weights' => [] === $weights ? [400] : $weights,
'fallback' => $fallback,
];
}
if ([] === $fonts) {
throw new InvalidPage('Es wird mindestens eine Schrift benötigt.');
}
if (count($fonts) > self::MAX_FONTS) {
throw new InvalidPage(sprintf('Mehr als %d Schriften sind nicht vorgesehen.', self::MAX_FONTS));
}
return $fonts;
}
private function base(array $input): array
{
$base = [...self::DEFAULTS['base']];
foreach (['fontFamily', 'headingFontFamily'] as $field) {
$base[$field] = strtolower(trim((string) ($input[$field] ?? $base[$field])));
}
foreach (['color', 'backgroundColor', 'linkColor'] as $field) {
$value = trim((string) ($input[$field] ?? $base[$field]));
if (1 !== preg_match(StyleSchema::COLOR, $value)) {
throw new InvalidPage(sprintf('„%s“ ist kein Farbwert.', $value));
}
$base[$field] = $value;
}
foreach (['fontSize', 'containerWidth'] as $field) {
$value = trim((string) ($input[$field] ?? $base[$field]));
if (1 !== preg_match(StyleSchema::LENGTH, $value)) {
throw new InvalidPage(sprintf('„%s“ ist keine Größenangabe wie 18px oder 70rem.', $value));
}
$base[$field] = $value;
}
foreach (['lineHeight' => [0.8, 3.0], 'headingScale' => [1.0, 2.0]] as $field => [$min, $max]) {
$value = (float) ($input[$field] ?? $base[$field]);
if ($value < $min || $value > $max) {
throw new InvalidPage(sprintf('„%s“ liegt außerhalb des zulässigen Bereichs.', $field));
}
$base[$field] = $value;
}
return $base;
}
/** The stylesheet every page carries: palette variables, font variables and base typography. */
public function css(array $theme): string
{
$variables = [];
foreach ($theme['colors'] as $color) {
$variables[] = sprintf('--k-%s:%s', $color['key'], $color['value']);
}
foreach ($theme['fonts'] as $font) {
$stack = '' === $font['family'] ? $font['fallback'] : sprintf('"%s",%s', $font['family'], $font['fallback']);
$variables[] = sprintf('--kf-%s:%s', $font['key'], $stack);
}
$base = $theme['base'];
$variables[] = sprintf('--k-container:%s', $base['containerWidth']);
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
$scale = (float) $base['headingScale'];
$headings = '';
// A modular scale from the base size, so one number changes the whole hierarchy.
foreach ([1 => 4, 2 => 3, 3 => 2, 4 => 1, 5 => 0, 6 => -1] as $level => $step) {
$headings .= sprintf('.kant h%d{font-size:calc(var(--k-size) * %.4f)}', $level, $scale ** $step);
}
return sprintf(
':root{%s;--k-size:%s}.kant{font-family:var(--kf-%s);font-size:var(--k-size);line-height:%s;color:%s;background:%s}'
.'.kant h1,.kant h2,.kant h3,.kant h4,.kant h5,.kant h6{font-family:var(--kf-%s)}'
.'.kant a{color:%s}.kant .shell{max-width:var(--k-container)}%s',
implode(';', $variables),
$base['fontSize'],
$base['fontFamily'],
$base['lineHeight'],
$colour($base['color']),
$colour($base['backgroundColor']),
$base['headingFontFamily'],
$colour($base['linkColor']),
$headings,
);
}
/**
* The Google Fonts stylesheet for every family that comes from there, or null when none does.
* Chosen by the user; it means one request to a third party per page view.
*/
public function googleFontsUrl(array $theme): ?string
{
$families = [];
foreach ($theme['fonts'] as $font) {
if ('google' === $font['source'] && '' !== $font['family']) {
$families[] = sprintf('family=%s:wght@%s', str_replace(' ', '+', $font['family']), implode(';', $font['weights']));
}
}
return [] === $families ? null : 'https://fonts.googleapis.com/css2?'.implode('&', array_unique($families)).'&display=swap';
}
}
+22 -2
View File
@@ -7,6 +7,9 @@ use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PostRepository;
use App\Content\StyleCompiler;
use App\Content\StyleSchema;
use App\Content\Theme;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
@@ -20,7 +23,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/admin')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private CsrfTokenManagerInterface $csrf)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private CsrfTokenManagerInterface $csrf)
{
}
@@ -66,6 +69,23 @@ final class EditorController extends AbstractController
return $this->redirectToRoute('admin_editor', ['id' => $id]);
}
/** The editor reads the style vocabulary and the palette from here, so both stay in one place. */
#[Route('/api/design', name: 'admin_design', methods: ['GET'])]
public function design(): JsonResponse
{
$theme = $this->theme->load();
return $this->json([
'properties' => StyleSchema::properties(),
'devices' => StyleSchema::DEVICES,
'sides' => StyleSchema::sides(),
'corners' => StyleSchema::corners(),
'hoverable' => StyleSchema::hoverable(),
'colors' => $theme['colors'],
'fonts' => $theme['fonts'],
]);
}
#[Route('/pages/{id}/meta', name: 'admin_post_meta', methods: ['POST'])]
public function metadata(Request $request, string $id): Response
{
@@ -180,7 +200,7 @@ final class EditorController extends AbstractController
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
}
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'needsTabs' => false]);
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'needsTabs' => false, 'pageStyles' => $this->theme->css($this->theme->load()).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($this->theme->load())]);
}
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
throw new \InvalidArgumentException();
+2
View File
@@ -138,6 +138,8 @@ final class FormController extends AbstractController
'postLists' => [],
'postMeta' => null,
'needsTabs' => false,
'pageStyles' => '',
'googleFonts' => null,
'forms' => $states,
], $response);
$response->headers->set('Cache-Control', 'no-store');
+31 -4
View File
@@ -8,6 +8,8 @@ use App\Content\PageRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\StyleCompiler;
use App\Content\Theme;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -15,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private string $secret, private string $siteOrigin)
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
{
}
@@ -32,6 +34,14 @@ final class PageController extends AbstractController
return $this->redirectToRoute('home', status: 301);
}
$page = $this->pages->findPublished($path);
$styles = '';
$googleFonts = null;
if (null !== $page) {
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']);
$googleFonts = $this->theme->googleFontsUrl($theme);
}
if (null === $page) {
// An address that moved or was deliberately removed answers before the 404 does.
$redirect = $this->redirects->find($path);
@@ -50,25 +60,42 @@ final class PageController extends AbstractController
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
'breadcrumbs' => $this->breadcrumbs($page),
'structuredData' => $this->structuredData($page),
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
]);
}
return $this->shell($response, null !== $page && $this->hasType($page['document'], 'tabs'));
return $this->shell($response, null !== $page && $this->hasType($page['document'], 'tabs'), $styles, null !== $googleFonts);
}
/**
* Every public response carries the same headers. Scripting stays forbidden unless the page
* actually contains a tab group: only then may the browser load the one enhancement script,
* and only from this origin. Inline script is never allowed.
*
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
*/
private function shell(Response $response, bool $allowScript = false): Response
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false): Response
{
$styleSources = ["'self'"];
if ('' !== $styles) {
$styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true)));
}
$fontSources = ["'self'"];
if ($googleFonts) {
// Chosen deliberately: the stylesheet comes from Google and the files from its CDN.
$styleSources[] = 'https://fonts.googleapis.com';
$fontSources[] = 'https://fonts.gstatic.com';
}
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Content-Security-Policy', sprintf(
"default-src 'self'; script-src %s; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
$allowScript ? "'self'" : "'none'",
implode(' ', $styleSources),
implode(' ', $fontSources),
));
return $response;
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Controller;
use App\Content\Theme;
use App\Domain\InvalidPage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ThemeController extends AbstractController
{
public function __construct(private Theme $theme)
{
}
#[Route('/admin/theme', name: 'admin_theme', methods: ['GET'])]
public function index(): Response
{
return $this->render('admin/theme.html.twig', [
'theme' => $this->theme->load(),
'fallbacks' => Theme::FALLBACKS,
'weights' => Theme::WEIGHTS,
]);
}
#[Route('/admin/theme/save', name: 'admin_theme_save', methods: ['POST'])]
public function save(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
return $this->redirectToRoute('admin_theme');
}
$fonts = [];
foreach ($request->request->all('fonts') as $row) {
if (!is_array($row)) {
continue;
}
$row['weights'] = array_map('intval', (array) ($row['weights'] ?? []));
$fonts[] = $row;
}
try {
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'));
} catch (InvalidPage $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('admin_theme');
}
$this->addFlash('success', 'Das Design wurde gespeichert. Es gilt sofort auf allen Seiten.');
return $this->redirectToRoute('admin_theme');
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
<main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">FORMULARE</p><h1>Formulare</h1>
<p class="muted">Felder, Empfänger und Einwilligungstext werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.</p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a></p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a></p>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
+1 -1
View File
@@ -8,7 +8,7 @@
<main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">NAVIGATION</p><h1>Menüs</h1>
<p class="muted">Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine externe Adresse. Die Reihenfolge ergibt sich aus der Position, die Verschachtelung aus der Ebene.</p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a></p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a></p>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
+1 -1
View File
@@ -8,7 +8,7 @@
<main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1>
<p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</a></p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</a></p>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
+1 -1
View File
@@ -10,7 +10,7 @@
<p class="muted">Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.</p>
<p class="admin-links">
<a class="secondary" href="{{ path('admin_pages') }}">Seiten</a>
<a class="secondary" href="{{ path('admin_menus') }}">Menüs</a>
<a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a>
<a class="secondary" href="{{ path('admin_forms') }}">Formulare</a>
<a class="secondary" href="/sitemap.xml">Sitemap ↗</a>
<a class="secondary" href="/robots.txt">robots.txt ↗</a>
+125
View File
@@ -0,0 +1,125 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Design{% endblock %}
{% block body %}
<header class="admin-header">
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
<div class="header-actions"><a href="/">Website ansehen ↗</a><span class="user-name">{{ app.user.userIdentifier }}</span><form action="{{ path('admin_logout') }}" method="post"><input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"><button class="subtle" type="submit">Abmelden</button></form></div>
</header>
<main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">DESIGN</p><h1>Farben, Schriften, Grundtypografie</h1>
<p class="muted">Das ist die Grundlage für die ganze Website. Jeder Block erbt von hier und kann jeden Wert einzeln überschreiben – je Gerät und beim Überfahren.</p>
<p class="admin-links">
<a class="secondary" href="{{ path('admin_pages') }}">Seiten</a>
<a class="secondary" href="{{ path('admin_menus') }}">Menüs</a>
<a class="secondary" href="{{ path('admin_forms') }}">Formulare</a>
<a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a>
</p>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
{% endfor %}{% endfor %}
<form action="{{ path('admin_theme_save') }}" method="post" class="menu-form theme-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
<div class="section-heading"><h2>Farbpalette <span class="count">{{ theme.colors|length }}</span></h2><span class="muted">Beliebig viele eigene Farben</span></div>
<table class="menu-table">
<caption class="visually-hidden">Farben der Website. Einen Schlüssel zu leeren entfernt die Farbe.</caption>
<thead><tr><th scope="col">Schlüssel</th><th scope="col">Name</th><th scope="col">Farbe</th><th scope="col">Wert</th></tr></thead>
<tbody>
{% for row in theme.colors|merge([{key: '', name: '', value: '#000000'}, {key: '', name: '', value: '#000000'}, {key: '', name: '', value: '#000000'}]) %}
{% set n = loop.index0 %}
<tr{% if row.key == '' %} class="menu-row-new"{% endif %}>
<td><input name="colors[{{ n }}][key]" maxlength="32" value="{{ row.key }}" pattern="[a-z][a-z0-9-]*" aria-label="Schlüssel von Farbe {{ n + 1 }}" placeholder="akzent"></td>
<td><input name="colors[{{ n }}][name]" maxlength="60" value="{{ row.name }}" aria-label="Name von Farbe {{ n + 1 }}" placeholder="Akzent"></td>
<td><input type="color" name="colors[{{ n }}][value]" value="{{ row.value }}" aria-label="Farbwähler für Farbe {{ n + 1 }}" class="theme-swatch"></td>
<td><code>{{ row.value }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="field-help">Der Schlüssel wird im Editor als <code>@schlüssel</code> verwendet und bleibt bei Farbänderungen gleich – eine Farbe ändern wirkt überall, wo sie benutzt wird.</p>
<div class="section-heading"><h2>Schriften <span class="count">{{ theme.fonts|length }}</span></h2><span class="muted">Systemschrift oder Google Fonts</span></div>
<table class="menu-table">
<caption class="visually-hidden">Schriften der Website. Einen Schlüssel zu leeren entfernt die Schrift.</caption>
<thead><tr><th scope="col">Schlüssel</th><th scope="col">Name</th><th scope="col">Quelle</th><th scope="col">Schriftname</th><th scope="col">Ersatz</th><th scope="col">Schnitte</th></tr></thead>
<tbody>
{% for row in theme.fonts|merge([{key: '', name: '', family: '', source: 'google', fallback: 'sans-serif', weights: [400, 700]}]) %}
{% set n = loop.index0 %}
<tr{% if row.key == '' %} class="menu-row-new"{% endif %}>
<td><input name="fonts[{{ n }}][key]" maxlength="40" value="{{ row.key }}" pattern="[a-z][a-z0-9-]*" aria-label="Schlüssel von Schrift {{ n + 1 }}" placeholder="heading"></td>
<td><input name="fonts[{{ n }}][name]" maxlength="60" value="{{ row.name }}" aria-label="Name von Schrift {{ n + 1 }}" placeholder="Überschriften"></td>
<td>
<select name="fonts[{{ n }}][source]" aria-label="Quelle von Schrift {{ n + 1 }}">
<option value="system"{{ row.source == 'system' ? ' selected' : '' }}>Systemschrift</option>
<option value="google"{{ row.source == 'google' ? ' selected' : '' }}>Google Fonts</option>
</select>
</td>
<td><input name="fonts[{{ n }}][family]" maxlength="48" value="{{ row.family }}" aria-label="Schriftname von Schrift {{ n + 1 }}" placeholder="Inter"></td>
<td>
<select name="fonts[{{ n }}][fallback]" aria-label="Ersatzschrift von Schrift {{ n + 1 }}">
{% for value, label in fallbacks %}<option value="{{ value }}"{{ row.fallback == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
</select>
</td>
<td class="weights-cell">
{% for weight in weights %}
<label><input type="checkbox" name="fonts[{{ n }}][weights][]" value="{{ weight }}"{{ weight in row.weights ? ' checked' : '' }}> {{ weight }}</label>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="field-help">Bei Google Fonts den Namen genau wie dort schreiben, zum Beispiel <code>Inter</code> oder <code>Playfair Display</code>. Je Schnitt wird eine Datei geladen – nur ankreuzen, was wirklich gebraucht wird. <strong>Google Fonts bedeutet eine Anfrage an Google bei jedem Seitenaufruf.</strong></p>
<div class="section-heading"><h2>Grundtypografie</h2><span class="muted">Gilt überall, solange ein Block nichts anderes sagt</span></div>
<div class="create-grid">
<div>
<label for="base-font">Schrift für Fließtext</label>
<select id="base-font" name="base[fontFamily]">
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
</select>
</div>
<div>
<label for="base-heading-font">Schrift für Überschriften</label>
<select id="base-heading-font" name="base[headingFontFamily]">
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.headingFontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
</select>
</div>
<div>
<label for="base-size">Grundgröße</label>
<input id="base-size" name="base[fontSize]" value="{{ theme.base.fontSize }}" placeholder="18px">
</div>
<div>
<label for="base-line">Zeilenhöhe</label>
<input id="base-line" name="base[lineHeight]" type="number" step="0.05" min="0.8" max="3" value="{{ theme.base.lineHeight }}">
</div>
<div>
<label for="base-scale">Überschriftenskala</label>
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
</div>
<div>
<label for="base-container">Inhaltsbreite</label>
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
</div>
<div>
<label for="base-color">Textfarbe</label>
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink">
</div>
<div>
<label for="base-bg">Hintergrundfarbe</label>
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper">
</div>
<div>
<label for="base-link">Linkfarbe</label>
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent">
</div>
</div>
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
<button class="primary" type="submit">Design speichern</button>
</form>
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
</main>
{% endblock %}
+1 -1
View File
@@ -13,7 +13,7 @@
<link rel="stylesheet" href="/assets/site.css">
<link rel="stylesheet" href="/assets/design.css">
</head>
<body>
<body class="kant">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
<header class="site-header shell">
<a class="brand" href="/" aria-label="Kant CMS – Startseite"><span class="brand-icon" aria-hidden="true">k.</span> kant<span class="brand-light">cms</span></a>
+3 -1
View File
@@ -14,6 +14,7 @@
{% if socialImage|default(null) %}<meta property="og:image" content="{{ socialImage }}"><meta name="twitter:card" content="summary_large_image">{% endif %}
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/assets/site.css">
{% if googleFonts|default(null) %}<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="{{ googleFonts }}">{% endif %}
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css">{% endif %}
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css">{% endif %}
@@ -21,8 +22,9 @@
{# A data block, not a script: CSP script-src does not apply to application/ld+json. #}
{% for block in structuredData|default([]) %}<script type="application/ld+json">{{ block|json_encode(constant('JSON_UNESCAPED_SLASHES') b-or constant('JSON_UNESCAPED_UNICODE'))|raw }}</script>
{% endfor %}
{% if pageStyles|default('') %}<style>{{ pageStyles|raw }}</style>{% endif %}
</head>
<body>
<body class="kant">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
<header class="site-header shell">
<a class="brand" href="/" aria-label="Kant CMS – Startseite"><span class="brand-icon" aria-hidden="true">k.</span> kant<span class="brand-light">cms</span></a>
+161
View File
@@ -10,6 +10,7 @@ use App\Content\PageRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Domain\InvalidDocument;
use App\Domain\InvalidMenu;
use App\Domain\InvalidPage;
@@ -39,6 +40,7 @@ final class CmsTest extends TestCase
private PostRepository $posts;
private SiteSettings $site;
private RedirectRepository $redirects;
private Theme $themes;
private string $path;
protected function setUp(): void
@@ -67,6 +69,7 @@ final class CmsTest extends TestCase
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'));
$this->site = new SiteSettings($this->db);
$this->redirects = new RedirectRepository($this->db);
$this->themes = new Theme($this->db);
}
protected function tearDown(): void
@@ -1048,6 +1051,164 @@ final class CmsTest extends TestCase
self::assertSame(1, substr_count($body, 'Sitemap:'));
}
private static function styledDocument(): array
{
$document = DemoContent::starter('Gestaltet');
$document['schemaVersion'] = 10;
$document['blocks'][0]['style'] = [
'desktop' => [
'backgroundColor' => '@mint',
'color' => '#123456',
'padding' => ['top' => '80px', 'bottom' => '80px'],
'borderRadius' => ['topLeft' => '24px'],
'fontSize' => '42px',
'letterSpacing' => '-1.5px',
'shadowY' => '12px', 'shadowBlur' => '40px', 'shadowColor' => '#00000022',
'hover' => ['backgroundColor' => '#ffffff', 'scale' => 1.02],
],
'mobile' => ['fontSize' => '28px', 'padding' => ['top' => '32px']],
];
return $document;
}
public function testFreeStylesSurviveSavingAndBecomeScopedCss(): void
{
$document = self::styledDocument();
$revision = $this->pages->saveDraft('page-1', $document, 0);
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
$this->pages->publish('page-1', $revision, 1);
$html = $this->request('/')->getContent();
// Values become declarations scoped to the block, palette colours become variables.
self::assertStringContainsString('#start-hero{', $html);
self::assertStringContainsString('background-color:var(--k-mint)', $html);
self::assertStringContainsString('color:#123456', $html);
self::assertStringContainsString('padding-top:80px', $html);
self::assertStringContainsString('border-top-left-radius:24px', $html);
self::assertStringContainsString('letter-spacing:-1.5px', $html);
self::assertStringContainsString('box-shadow:0 12px 40px 0 #00000022', $html);
// Hover and the narrow breakpoint get their own rules.
self::assertStringContainsString('#start-hero:hover,#start-hero:focus-within{', $html);
self::assertStringContainsString('transform:scale(1.02)', $html);
self::assertStringContainsString('@media (max-width:767px){#start-hero{font-size:28px', $html);
}
public function testInlineStylesheetIsAllowedByItsOwnHashAndNothingElse(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', self::styledDocument(), 0), 1);
$response = $this->request('/');
$policy = (string) $response->headers->get('Content-Security-Policy');
preg_match('#<style>(.*?)</style>#s', $response->getContent(), $match);
self::assertNotEmpty($match[1] ?? '', 'The page carries a generated stylesheet.');
$expected = base64_encode(hash('sha256', html_entity_decode($match[1], ENT_QUOTES), true));
self::assertStringContainsString(sprintf("'sha256-%s'", $expected), $policy);
// Never a blanket permission for inline styles, and scripting stays off.
self::assertStringNotContainsString('unsafe-inline', $policy);
self::assertStringContainsString("script-src 'none'", $policy);
}
/**
* @return iterable<string, array{array}>
*/
public static function rejectedStyles(): iterable
{
$wrap = static function (array $style): array {
$document = DemoContent::starter('Titel');
$document['schemaVersion'] = 10;
$document['blocks'][0]['style'] = $style;
return [$document];
};
yield 'unknown property' => $wrap(['desktop' => ['zIndexHack' => '9999']]);
yield 'unknown device' => $wrap(['watch' => ['fontSize' => '12px']]);
yield 'colour that is a word' => $wrap(['desktop' => ['color' => 'red']]);
yield 'colour with a function' => $wrap(['desktop' => ['color' => 'rgb(1,2,3)']]);
yield 'length without unit' => $wrap(['desktop' => ['fontSize' => '18']]);
yield 'length with expression' => $wrap(['desktop' => ['fontSize' => 'calc(1px + 2px)']]);
yield 'value with a declaration' => $wrap(['desktop' => ['fontSize' => '18px;position:fixed']]);
yield 'choice outside the list' => $wrap(['desktop' => ['display' => 'contents']]);
yield 'number out of range' => $wrap(['desktop' => ['opacity' => 4]]);
yield 'side given as a single value' => $wrap(['desktop' => ['padding' => '10px']]);
yield 'unknown side' => $wrap(['desktop' => ['padding' => ['middle' => '10px']]]);
yield 'hover on a property without hover' => $wrap(['desktop' => ['hover' => ['fontSize' => '10px']]]);
yield 'font key with quotes' => $wrap(['desktop' => ['fontFamily' => '"Inter",serif']]);
yield 'background that is not a media id' => $wrap(['desktop' => ['backgroundImage' => '/etc/passwd']]);
}
#[DataProvider('rejectedStyles')]
public function testRejectsInvalidStylesBeforeWriting(array $document): void
{
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testFreeStylesNeedSchemaTen(): void
{
$document = self::styledDocument();
$document['schemaVersion'] = 9;
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
// Without a stored theme the defaults apply and no third party is contacted.
$plain = $this->request('/');
self::assertStringContainsString('--k-ink:#203b34', $plain->getContent());
self::assertStringNotContainsString('fonts.googleapis.com', $plain->getContent());
self::assertStringNotContainsString('fonts.gstatic.com', (string) $plain->headers->get('Content-Security-Policy'));
$this->themes->save(
[['key' => 'marke', 'name' => 'Marke', 'value' => '#Ff3300'], ['key' => 'ink', 'name' => 'Tinte', 'value' => '#111111']],
[['key' => 'body', 'name' => 'Text', 'family' => 'Inter', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'sans-serif']],
['fontFamily' => 'body', 'headingFontFamily' => 'body', 'fontSize' => '20px', 'lineHeight' => 1.7, 'color' => '@ink', 'backgroundColor' => '#ffffff', 'linkColor' => '@marke', 'containerWidth' => '1100px', 'headingScale' => 1.3],
);
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('--k-marke:#ff3300', $html, 'colour values are normalised');
self::assertStringContainsString('--kf-body:"Inter",sans-serif', $html);
self::assertStringContainsString('font-size:var(--k-size)', $html);
self::assertStringContainsString('--k-container:1100px', $html);
self::assertStringContainsString('fonts.googleapis.com/css2?family=Inter:wght@400;700', $html);
$policy = (string) $response->headers->get('Content-Security-Policy');
self::assertStringContainsString('https://fonts.googleapis.com', $policy);
self::assertStringContainsString("font-src 'self' https://fonts.gstatic.com", $policy);
}
/**
* @return iterable<string, array{array, array, array}>
*/
public static function rejectedThemes(): iterable
{
$font = ['key' => 'body', 'name' => 'Text', 'family' => '', 'source' => 'system', 'weights' => [400], 'fallback' => 'system-ui'];
$base = ['fontFamily' => 'body', 'headingFontFamily' => 'body', 'color' => '@ink', 'backgroundColor' => '#ffffff', 'linkColor' => '#000000', 'fontSize' => '18px', 'containerWidth' => '1240px', 'lineHeight' => 1.6, 'headingScale' => 1.25];
yield 'colour that is not hex' => [[['key' => 'ink', 'name' => 'T', 'value' => 'darkgreen']], [$font], $base];
yield 'duplicate colour key' => [[['key' => 'ink', 'name' => 'A', 'value' => '#000000'], ['key' => 'ink', 'name' => 'B', 'value' => '#111111']], [$font], $base];
yield 'font family with a stack' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [[...$font, 'family' => 'Inter,serif']], $base];
yield 'google font without a name' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [[...$font, 'source' => 'google']], $base];
yield 'base colour without palette entry' => [[['key' => 'other', 'name' => 'T', 'value' => '#000000']], [$font], $base];
yield 'base size without unit' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [$font], [...$base, 'fontSize' => '18']];
yield 'line height out of range' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [$font], [...$base, 'lineHeight' => 9]];
yield 'no colour at all' => [[], [$font], $base];
}
#[DataProvider('rejectedThemes')]
public function testRejectsInvalidThemesWithoutWriting(array $colors, array $fonts, array $base): void
{
$before = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
try {
$this->themes->save($colors, $fonts, $base);
self::fail('Expected the theme to be rejected.');
} catch (InvalidPage) {
}
self::assertSame($before, $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
self::assertSame(Theme::DEFAULTS['colors'], $this->themes->load()['colors']);
}
private function request(string $url, string $method = 'GET'): Response
{
$previous = [];
+35 -1
View File
@@ -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 } from '../public/assets/editor-model.js';
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 { readFile } from 'node:fs/promises';
import { designCss } from '../scripts/build-design.mjs';
@@ -234,3 +234,37 @@ test('a new tab group starts with two tabs and accepts nothing but tabs', () =>
history.undo();
assert.deepEqual(history.document, fixture());
});
test('free styles inherit from the wider device, reset individually and leave no skeleton', () => {
const history = new EditorHistory(fixture());
history.change(doc => setStyle(doc, 's1', 'desktop', 'fontSize', '42px'));
history.change(doc => setStyle(doc, 's1', 'desktop', 'padding', '80px', { side: 'top' }));
history.change(doc => setStyle(doc, 's1', 'desktop', 'color', '#ffffff', { hover: true }));
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', '28px'));
const block = () => locate(history.document.blocks, 's1').block;
assert.equal(history.document.schemaVersion, 10);
// Tablet has no own value, so it inherits the desktop one; mobile keeps its own.
assert.deepEqual(effectiveStyle(block(), 'tablet', 'fontSize'), { value: '42px', source: 'desktop' });
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '28px', source: 'mobile' });
assert.deepEqual(effectiveStyle(block(), 'desktop', 'padding', 'top'), { value: '80px', source: 'desktop' });
assert.equal(hoverStyle(block(), 'desktop', 'color'), '#ffffff');
// Clearing one value makes it inherit again and removes the now empty container.
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', ''));
assert.equal(block().style.mobile, undefined);
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '42px', source: 'desktop' });
// Copying carries the styling over without sharing objects.
history.change(doc => copyStyle(doc, 's1', 's2'));
const copy = locate(history.document.blocks, 's2').block;
assert.deepEqual(copy.style, block().style);
assert.notEqual(copy.style, block().style);
history.change(doc => clearStyle(doc, 's1'));
assert.equal(block().style, undefined);
// Every step is one history entry, so undoing all of them restores the original document.
for (let i = 0; i < 7; i++) history.undo();
assert.deepEqual(history.document, fixture());
});