From abf0bd2c6b63cbb79587c224e5130806f44e64fa Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Sat, 12 Sep 2026 13:04:54 +0200 Subject: [PATCH] Freie Gestaltung statt Tokenlisten (Dokumentschema v10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ARCHITECTURE.md | 20 +++ docs/QUALITY.md | 11 ++ docs/STATUS.md | 14 +- public/assets/admin.css | 37 ++++ public/assets/design-panel.js | 255 +++++++++++++++++++++++++++ public/assets/editor-model.js | 93 +++++++++- public/assets/editor.js | 42 ++++- src/Content/DocumentValidator.php | 12 +- src/Content/StyleCompiler.php | 264 ++++++++++++++++++++++++++++ src/Content/StyleSchema.php | 157 +++++++++++++++++ src/Content/Theme.php | 254 ++++++++++++++++++++++++++ src/Controller/EditorController.php | 24 ++- src/Controller/FormController.php | 2 + src/Controller/PageController.php | 35 +++- src/Controller/ThemeController.php | 56 ++++++ templates/admin/forms.html.twig | 2 +- templates/admin/menus.html.twig | 2 +- templates/admin/pages.html.twig | 2 +- templates/admin/settings.html.twig | 2 +- templates/admin/theme.html.twig | 125 +++++++++++++ templates/archive.html.twig | 2 +- templates/page.html.twig | 4 +- tests/CmsTest.php | 161 +++++++++++++++++ tests/editor-model.test.mjs | 36 +++- 24 files changed, 1591 insertions(+), 21 deletions(-) create mode 100644 public/assets/design-panel.js create mode 100644 src/Content/StyleCompiler.php create mode 100644 src/Content/StyleSchema.php create mode 100644 src/Content/Theme.php create mode 100644 src/Controller/ThemeController.php create mode 100644 templates/admin/theme.html.twig diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index faa328c..49821bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 80f8994..e4870bd 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -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**. diff --git a/docs/STATUS.md b/docs/STATUS.md index e5be100..760f0dc 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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. diff --git a/public/assets/admin.css b/public/assets/admin.css index 2b69995..b6e2303 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -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} diff --git a/public/assets/design-panel.js b/public/assets/design-panel.js new file mode 100644 index 0000000..f8c3316 --- /dev/null +++ b/public/assets/design-panel.js @@ -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); + } +} diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 1da91fd..a998941 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -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; +} diff --git a/public/assets/editor.js b/public/assets/editor.js index c924bfb..e554938 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -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(); diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index 381531d..f9aa589 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -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.'); diff --git a/src/Content/StyleCompiler.php b/src/Content/StyleCompiler.php new file mode 100644 index 0000000..958500f --- /dev/null +++ b/src/Content/StyleCompiler.php @@ -0,0 +1,264 @@ + 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); + } + } +} diff --git a/src/Content/StyleSchema.php b/src/Content/StyleSchema.php new file mode 100644 index 0000000..a013c6b --- /dev/null +++ b/src/Content/StyleSchema.php @@ -0,0 +1,157 @@ + '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, 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); + } +} diff --git a/src/Content/Theme.php b/src/Content/Theme.php new file mode 100644 index 0000000..5792183 --- /dev/null +++ b/src/Content/Theme.php @@ -0,0 +1,254 @@ + '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'; + } +} diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index f7132be..964c560 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -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(); diff --git a/src/Controller/FormController.php b/src/Controller/FormController.php index b58148f..6691246 100644 --- a/src/Controller/FormController.php +++ b/src/Controller/FormController.php @@ -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'); diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 13f05e3..fbce483 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -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; diff --git a/src/Controller/ThemeController.php b/src/Controller/ThemeController.php new file mode 100644 index 0000000..7540f15 --- /dev/null +++ b/src/Controller/ThemeController.php @@ -0,0 +1,56 @@ +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'); + } +} diff --git a/templates/admin/forms.html.twig b/templates/admin/forms.html.twig index d1f0753..f40ed13 100644 --- a/templates/admin/forms.html.twig +++ b/templates/admin/forms.html.twig @@ -8,7 +8,7 @@

FORMULARE

Formulare

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.

- + {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}

{{ message }}

diff --git a/templates/admin/menus.html.twig b/templates/admin/menus.html.twig index 72d2015..0af19dd 100644 --- a/templates/admin/menus.html.twig +++ b/templates/admin/menus.html.twig @@ -8,7 +8,7 @@

NAVIGATION

Menüs

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.

- + {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}

{{ message }}

diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig index 0e74b45..8415887 100644 --- a/templates/admin/pages.html.twig +++ b/templates/admin/pages.html.twig @@ -8,7 +8,7 @@

DEINE WEBSITE

Gute Inhalte beginnen hier.

Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.

- + {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}

{{ message }}

diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index 18a9e5f..d335ddf 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -10,7 +10,7 @@

Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.

+ kantcms +
Website ansehen ↗{{ app.user.userIdentifier }}
+
+
+

DESIGN

Farben, Schriften, Grundtypografie

+

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.

+ + + {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %} +

{{ message }}

+ {% endfor %}{% endfor %} + + +

Kant CMS · Visueller Editor, erster Prototyp

+
+{% endblock %} diff --git a/templates/archive.html.twig b/templates/archive.html.twig index 4781aaa..727cf84 100644 --- a/templates/archive.html.twig +++ b/templates/archive.html.twig @@ -13,7 +13,7 @@ - +