Ein Hinweis, der nur fragt und sonst nichts ändert, ist Theater. Deshalb zwei Hälften, und die zweite ist die wichtigere. Die Frage: serverseitig gerendert, per Formular beantwortet, ohne JavaScript - die öffentlichen Seiten laufen mit script-src 'none', und ein Banner mit Skript wäre das eine, was diese Regel bricht. "Alle akzeptieren" und "Nur notwendige" sind dieselbe Art Knopf, gleich groß, nebeneinander. Die Entscheidung steht in einem eigenen Cookie: Fassung der Frage, gewährte Gruppen, Zeitpunkt. Keine Kennung, kein Serverabgleich, ein halbes Jahr. Kein CSRF-Token, absichtlich: die Anfrage ändert nichts auf dem Server, und ein Token bräuchte für jeden Besucher eine Sitzung - genau das, was ein Einwilligungshinweis vermeiden soll. Geprüft wird stattdessen das Rücksprungziel. Das Zurückhalten: der neue Block "Externer Inhalt" (Dokumentschema v14) lädt ohne Einwilligung nichts - kein iframe, keine Adresse im Markup, frame-src 'none'. Der Platzhalter nennt den Anbieter; sein Knopf gewährt genau diese Gruppe und nimmt die vorher gewährten mit. Erst danach kommt der Host in die Sicherheitsrichtlinie, und nur dieser. Im Editor bleibt es immer ein Platzhalter. Datenschutz und Impressum sind keine Einstellung mehr: fehlen sie, warnt die Verwaltung und legt sie auf Knopfdruck als Gerüst an, mit NOCH AUSZUFÜLLEN an jeder Pflichtangabe. Den Text schreibt das CMS nicht - das ist eine rechtliche Aussage über eine Firma. Verlinkt werden beide aus dem Template, nicht aus dem Fußbereich: ein Pflichtlink, den man löschen kann, ist keiner. Dazu der Fehler, den Umut im Screenshot gesehen hat: die Wortmarke stand als Buchstabenkolonne in einem 60 Pixel breiten Kasten. .site-header war noch ein Flexcontainer aus der Zeit des handgeschriebenen Markups und quetschte die Sektion zusammen, und design.css band jede Designklasse an #main - wo Kopf- und Fußbereich seit ADR 026 nicht mehr liegen. Beides behoben. Neu im Ausbauplan: Statistik (serverseitige, wiedererkennungsfreie Zählung, die deshalb keine Einwilligung braucht), Inhalte und Ladeverhalten prüfen (Blättern überall, minimal laden, Ladebudget), Installer (Schritt für Schritt im Browser, Migration inklusive). 245 PHP-Tests / 976 Assertions unter PHP 8.2.12 und 8.4.22. Der Browsertest fand zwei Fehler von mir: grauer Text auf dunklem Fußbereich mit 2,24:1, und das fixierte Banner verdeckte den Einwilligungsknopf am Seitenende. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
422 lines
71 KiB
Markdown
422 lines
71 KiB
Markdown
# Architekturentscheidungen
|
||
|
||
Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||
|
||
## ADR 001 – PHP-Webhosting, Symfony 7.4
|
||
|
||
Umut hat im laufenden Auftrag ausdrücklich herkömmliches PHP-Webhosting gewählt. Deshalb PHP statt des zunächst aus dem Ordnernamen vermuteten Django-Stacks. Symfony 7.4 LTS benötigt PHP ab 8.2 und bringt Routing, Dependency Injection, Konfiguration und später anschließbare Sicherheitskomponenten mit. Twig maskiert Inhalte standardmäßig. Doctrine DBAL und Migrations übernehmen Datenbankzugriff und Migrationen. Keine eigene Authentifizierung erfinden: vor HTTP-Schreibzugriffen Symfony Security, CSRF-Schutz und serverseitige Rollen ergänzen.
|
||
|
||
Eine Kundeninstallation ist eine Anwendung mit eigenem Datenbestand. Es gibt keine Abhängigkeit von einem dauerhaften Node-, Python- oder Queue-Prozess. Öffentliche Dateien liegen in `public/`, der Projektstamm muss außerhalb des Webroots bleiben. Konkreter Hostinganbieter, PHP-Version, Datenbank und CLI-/Deploymentmöglichkeiten sind noch offen.
|
||
|
||
Alternativen: Django passt nicht zum gewählten PHP-Profil. Laravel ist möglich, Symfony wurde wegen der LTS-Linie und modularen Grundlage gewählt. Ein selbst geschriebenes Mini-Framework würde Standardfunktionen unnötig neu implementieren.
|
||
|
||
Quellen bei Einrichtung geprüft: [Symfony 7.4 Setup](https://symfony.com/doc/7.4/setup.html), [Symfony-Versionen](https://symfony.com/releases). Exakte installierte Versionen stehen im Composer-Lockfile; PHP-Untergrenze wird im Composer-Resolver auf 8.2.12 simuliert. Auf dem Server dennoch aktuellen Sicherheits-Patchstand verwenden.
|
||
|
||
## ADR 002 – Versionierte Inhalte und Veröffentlichung
|
||
|
||
Dokumentschema v1: `schemaVersion`, `title`, `description`, `blocks`. Jeder Block hat eine stabile `id`, einen `type`, inhaltsbezogene `props`, optionale tokenbasierte `layout`-Werte und bei Containern `children`. Globale Designwerte gehören zum Website-/Template-Vertrag, nicht zu Texten. Die erste feste CSS-Vorlage nutzt bereits getrennte Designvariablen; konfigurierbare Themes folgen später.
|
||
|
||
Unterstützte Blöcke: section, row, column, heading, text, image, button. Alle bilden einen Baum; Layoutwerte sind Allowlist-Tokens, kein beliebiges CSS. Texte sind Klartext. Links erlauben lokale absolute Pfade, Fragmente oder HTTPS. Bilder benötigen Abmessungen und eine explizite Aussage, ob sie dekorativ sind. Begrenzungen: 256 KiB Dokument, 300 Blöcke, Tiefe 10, eindeutige IDs, genau eine Hauptüberschrift. Die aktuellen Textgrenzen zählen UTF-8-Bytes. Medienoptimierung/Uploads sind noch nicht enthalten.
|
||
|
||
Unbekannte Schemaversionen und Blocktypen werden beim Schreiben abgewiesen; die ursprüngliche Revision bleibt erhalten. Der Renderer enthält einen Platzhalter, aber der derzeit strikte Lesevalidator lässt unbekannte Blöcke noch nicht bis dorthin gelangen. Ein erhaltender Plugin-Fallback samt Registry ist ausdrücklich Phase 4, nicht bereits funktionsfähig.
|
||
|
||
Revisionen sind über den Repository-Vertrag unveränderlich. Die Datenbank schützt die Referenzen Website→Seite und Seite→Revision mit Fremdschlüsseln. Revisionszeiger auf einer Seite werden wegen des Einfügezyklus im Repository geprüft; zusätzlich fordert der öffentliche JOIN dieselbe Seiten-ID. Externe direkte Datenbankänderungen sind kein unterstützter Schreibweg.
|
||
|
||
Jedes Speichern/Publizieren erhöht `page.version`. Ein bedingtes UPDATE mit erwarteter Version erkennt veraltete Schreibversuche; Revisionseinfügung und Zeigeränderung laufen in einer Transaktion. Dadurch bleibt bei einem Konflikt auch keine verwaiste Revision zurück. Publizieren einer historischen Revision stellt deren Inhalt wieder her, der aktuelle Entwurf bleibt unabhängig erhalten.
|
||
|
||
## ADR 003 – Datenmodell
|
||
|
||
| Entität | Bereits implementiert / vorgesehener Vertrag |
|
||
| --- | --- |
|
||
| Website | ID, Name, Standardsprache, JSON-Theme-Konfiguration; aktuell eine Installation mit ID `default`. |
|
||
| Seite | ID, Website-ID, Slug, Sprache, Entwurfs-/Veröffentlichungszeiger, Schreibversion; Slug je Website und Sprache eindeutig. |
|
||
| Revision | ID, Seiten-ID, vollständiges versioniertes Dokument, UTC-Erstellungszeit; nächste Ausbaustufe: Benutzer/Änderungsnotiz. |
|
||
| Block | Innerhalb des Dokuments mit stabiler ID; keine Einzelblocktabelle. |
|
||
| CMS-Benutzer | ID, eindeutiger normalisierter Benutzername, Passwort-Hash, Rollen; hinzugefügt durch Migration 20260912000200. |
|
||
| Medium (Entwurf) | ID, Originaldatei, MIME-Typ, Maße, Varianten, Prüfsumme; Alt-Text kontextbezogen pro Verwendung. Noch keine Tabelle. |
|
||
| Menü (Entwurf) | ID, Website, Sprache, stabile Menüpunkt-IDs und Seitenreferenzen statt hart codierter Ziel-URLs. Aktuell feste Navigation. |
|
||
| Template (Entwurf) | Manifest-ID, Version, unterstützte Block-/Schemaschnittstellen, Design-Tokens; derzeit feste Twig-/CSS-Vorlage. |
|
||
|
||
Sprachkennung und entsprechende Eindeutigkeit sind gespeichert; öffentliche Sprachwahl und Übersetzungsoberfläche fehlen. Seitentitel/Beschreibung sind Teil der Revision, damit Entwurfsmetadaten nicht öffentlich durchsickern. Sitemap, strukturierte Daten, Weiterleitungen und umfassende SEO-Einstellungen folgen später.
|
||
|
||
## ADR 004 – Ausgabe, Cache und Ressourcen
|
||
|
||
Twig rendert vollständiges semantisches HTML auf dem Server. Der öffentliche Pfad lädt nur lokales CSS und ein SVG-Favicon, kein JavaScript. Der Editor unter `/admin` hat separate CSS-/JS-Ressourcen. Sein geschützter Preview-Endpunkt rendert `page.html.twig` mit demselben validierten Blockbaum wie die öffentliche Seite.
|
||
|
||
Aktuell kein Inhaltscache: pro Anfrage wird die veröffentlichte Revision gelesen und `Cache-Control: no-store` gesetzt. Damit gibt es keine veralteten Seiten nach Veröffentlichung. Twig-/Containercache enthält nur Programmstruktur. Ein späterer Inhaltscache muss Revisions-IDs im Schlüssel und gezielte Invalidierung für Navigation/Sitemap berücksichtigen; die entsprechenden Tests sind dann auszubauen.
|
||
|
||
Canonical entsteht aus konfiguriertem `SITE_ORIGIN`, nicht dem ungeprüften Request-Host. `/home` leitet auf `/` um. Unbekannte oder unveröffentlichte Seiten liefern 404. Die öffentlichen Routen akzeptieren keine Schreibmethoden. Die geschützten Admin-Endpunkte sind in ADR 006 beschrieben.
|
||
|
||
## ADR 005 – Migrationen und Reproduzierbarkeit
|
||
|
||
`Version20260912000100` ist das erste Schema. Doctrine erzeugt plattformspezifisches SQL. SQLite lokal getestet; MySQL-Smokejob in CI vorbereitet, noch nicht tatsächlich ausgeführt. MariaDB ist noch nicht getestet. Keine Behauptung identischer Transaktions- oder Hostingbedingungen ohne Prüfung.
|
||
|
||
Alle künftigen Datenbankänderungen erhalten eine neue Migration; diese erste Migration später nicht nachträglich umschreiben. Dokumentänderungen brauchen explizite `vN → vN+1`-Konverter und Roundtrip-/Erhaltungstests. Die erste Migration verweigert ein zerstörerisches `down`: Rückkehr durch geprüftes Datenbankbackup und dazu passenden Code. MySQL-DDL ist nicht vollständig transaktional; vor Migrationen sichern und Wiederherstellung separat testen.
|
||
|
||
Lockfiles sind versionierbar, lokale DB/Secrets/Abhängigkeiten und Messartefakte ausgeschlossen. Kein Git-Remote, kein externer Dienst und keine Veröffentlichung eingerichtet.
|
||
|
||
## ADR 006 – Anmeldung und geschützter Editor (12.09.2026)
|
||
|
||
Symfony Security übernimmt Formularanmeldung, Passwortprüfung/-Upgrade, Session-Fixation-Schutz, Logout und Login-Throttling (fünf Versuche pro Minute gemäß Framework-Limiter). Der DBAL-UserProvider lädt lokale Benutzer. Beim automatischen Passwort-Rehash werden Datenbank und In-Memory-Benutzer synchron aktualisiert, damit die nächste Sessionprüfung nicht das gerade angemeldete Konto abmeldet. `EquatableInterface` vergleicht ID, Name, Passwort-Hash und Rollen; Änderungen der Berechtigungen oder Zugangsdaten invalidieren eine bestehende Sitzung.
|
||
|
||
Firewall nur für `/admin`: öffentliche Seiten starten keine Session und erhalten keinen Editorcode. Cookies gelten für `/admin`, mit HttpOnly, SameSite=Lax und Secure abhängig von HTTPS. Sessions liegen außerhalb von `public` unter `var/sessions/`. Alle Adminantworten, auch Fehler und Redirects, erhalten `no-store` und `noindex`. Ein initiales Konto entsteht über ein lokales CLI-Kommando mit verdeckter Passwortabfrage. Keine offene Registrierung, keine Default-Zugangsdaten, keine Browser-Passwortänderung oder vollständige Benutzerverwaltung.
|
||
|
||
Rollen: `ROLE_EDITOR` darf Entwürfe lesen, vorschauen und speichern. `ROLE_PUBLISHER` darf zusätzlich veröffentlichen. `ROLE_ADMIN` erbt beide Rollen. Aktuell alle Seiten einer einzelnen Installation, noch keine Rechte je Seite oder Website. HTTP-POSTs erfordern einen Symfony-CSRF-Token. Veröffentlichung prüft Rolle, erwartete Version und die konkrete gespeicherte Entwurfsrevision. Veraltete Stände liefern HTTP 409. Rückmeldungen enthalten die durch diese Anfrage geschriebene Revision, statt möglicherweise einen unmittelbar danach fremd gespeicherten Stand als eigenen Erfolg zurückzugeben.
|
||
|
||
Die neue Migration ergänzt `cms_user`, ohne Inhaltstabellen umzuschreiben. Der erste Editor nutzte ausschließlich Dokumentformat v1. Den nachfolgenden Ausbau zu v2 beschreibt ADR 008.
|
||
|
||
## ADR 007 – Editorzustand und Vorschau
|
||
|
||
Kleiner Editor als lokale JavaScript-Module ohne Framework. Dokumentzustand, Tree-Operationen und Undo/Redo stehen getrennt in `editor-model.js`, DOM/Netzwerk in `editor.js`. Bis zu 100 vollständige lokale Undo-Stände; aufeinanderfolgende Eingaben in dasselbe Feld werden zu einem Schritt zusammengefasst. Das gespeicherte Dokument wird inhaltlich verglichen; Undo über einen Speicherpunkt zeigt dadurch wieder „ungespeichert“. Neuladen beendet den lokalen Verlauf. Serverseitige Revisionen sind davon unabhängig.
|
||
|
||
Die HTML-Vorschau läuft in einem Iframe mit `sandbox="allow-same-origin"` ohne `allow-scripts`. Der Elterneditor darf die Auswahl markieren und Klicks zuordnen; Inhalte können keine eigenen Skripte, Navigationen oder Formulare ausführen. Preview-Antworten werden bei neueren Änderungen sofort als veraltet markiert, laufende Requests abgebrochen und Eingaben kurz zusammengefasst. Bei Validierungsfehlern bleibt die letzte gültige Vorschau stehen. Gespeichert wird nur nach serverseitiger Validierung. Während Speichern/Veröffentlichen sind Bearbeitungsaktionen gesperrt.
|
||
|
||
Bei HTTP-/Netzwerkfehlern oder 409 bleibt der lokale Dokumentzustand erhalten. JSON-Export und expliziter Server-Neuladedialog ermöglichen die Konfliktauflösung ohne stilles Überschreiben. Der Export liegt als nutzerinitiierter Download außerhalb des Repositories. Kein Autosave und kein automatischer Merge.
|
||
|
||
Die Browserprüfungen nutzen eine eigene Datenbank und zufällige Testzugänge. axe benötigt Skriptausführung im geprüften Frame, die Sandbox erlaubt dies absichtlich nicht: daher Adminoberfläche ohne Frameinhalt prüfen und den identischen veröffentlichten Renderer separat prüfen. Die Sandbox wird nicht für Tests abgeschwächt. Klickauswahl/Rendering im echten Frame werden durch Playwright funktional getestet.
|
||
|
||
## ADR 008 – Responsive Gestaltung und Dokumentformat v2
|
||
|
||
v2 ergänzt optional `block.layout.design` mit den Bereichen `desktop`, `tablet` und `mobile`. Die bestehenden Inhalte, IDs, Baumstruktur sowie `tone` und `width` bleiben unverändert. Erlaubte Eigenschaften, Blocktypen und Werte stehen zentral in `public/assets/design-tokens.json`: Ausrichtung, Schriftgröße/-stärke, Zeilenhöhe, Innen-/Außenabstände, Spaltenzahl/-abstand und Ecken. Der PHP-Validator und die Editorfelder lesen dasselbe Manifest. `npm run build:design` erzeugt daraus die eingecheckte `design.css`; ein Test erkennt abweichende generierte Dateien. Beliebige CSS-Werte und unbekannte Geräteschlüssel werden vor dem Speichern abgewiesen.
|
||
|
||
Vererbung: Desktop ist die Basis auf allen Breiten. Tablet überschreibt bis einschließlich 1024 px, Mobil bis einschließlich 650 px. Fehlende Eigenschaften übernehmen den Wert des größeren Geräts oder die ursprünglichen Theme-Regeln. „Übernehmen“ entfernt genau eine Überschreibung; „Werte zurücksetzen“ entfernt die Werte des gewählten Geräts am gewählten Block. Größere Geräte und andere Blöcke bleiben erhalten. Alle Aktionen nehmen an Undo/Redo teil. Farben und Sektionsbreite bleiben globale Blockeinstellungen im Inhaltbereich.
|
||
|
||
Migration v1 → v2: `upgradeDocument` in `editor-model.js` ist ein idempotenter Konverter innerhalb einer geklonten History-Transaktion. Er ändert ausschließlich die Versionsnummer; `setDesign` ergänzt anschließend den gewählten Wert. Die erste Designänderung oder eine Vorlage mit responsiven Werten löst die Umstellung aus. Öffnen, reine Texteingaben und unverändertes Speichern migrieren v1 nicht. Erst ein explizites erfolgreiches Speichern schreibt eine neue v2-Revision. Keine Datenbankmigration oder automatische Umschreibung bestehender Revisionen. Der neue Server liest, rendert und schreibt beide Versionen. Unbekannte Zukunftsversionen werden abgewiesen.
|
||
|
||
Rückweg: Undo der ersten Designänderung stellt auch das ursprüngliche v1-Dokument wieder her. Historische v1-Revisionen bleiben unverändert verfügbar. Ein Code-Downgrade auf einen reinen v1-Server darf erst nach Wiederherstellung passender v1-Revisionszeiger oder eines geprüften Backups erfolgen; v2-Dokumente niemals durch bloßes Entfernen der Versionsnummer als v1 ausgeben. Nach Reset aller Werte bleibt ein gespeichertes v2-Dokument v2.
|
||
|
||
Twig bildet validierte Tokens auf statische Klassen ab. Nur v2-Seiten laden die zusätzliche CSS-Datei; die öffentliche Ausgabe enthält weiter kein JavaScript oder Inline-CSS. Vorschau und Veröffentlichung haben denselben Renderer und dieselben Media Queries. Die Vorschau besitzt echte Layoutbreiten von 1240/768/390 px und wird bei kleiner Editorfläche visuell skaliert. Die Skalierung betrifft nur die Editoroberfläche und verändert die CSS-Breakpoints im Iframe nicht.
|
||
|
||
Drei lokale Abschnittsvorlagen erzeugen gewöhnliche, vollständig bearbeitbare Blöcke mit neuen IDs und H2/H3 statt einer zweiten H1. Das ist keine Plugin- oder globale Template-API. Werkzeugkasten und Struktur lassen sich einklappen; Suche zeigt passende Elemente einschließlich ihrer Vorfahren. Suchzustand, Panelwahl und eingeklappte Knoten sind reine Oberflächenzustände und werden nicht im Dokument gespeichert.
|
||
|
||
## ADR 009 – Bilder, Galerie und Effekte (Dokumentschema v3)
|
||
|
||
Migration `20260912000300` ergänzt ausschließlich die Tabelle `media`: stabile ID, Titel, vorgeschlagener Alt-Text, unveränderliche Varianten, Archivstatus, Schreibversion und UTC-Erstellungszeit. Die lokale SQLite-Datenbank wurde vor Anwendung über `VACUUM INTO` konsistent gesichert. Keine Seiten oder Benutzer wurden umgeschrieben. Ein Restore muss künftig Datenbank **und** Medienverzeichnis gemeinsam wiederherstellen.
|
||
|
||
Uploads und Metadatenänderungen verlangen `ROLE_EDITOR` und einen eigenen CSRF-Token. Metadaten nutzen erwartete Versionen; konkurrierende Änderungen liefern 409. Bildtitel/Alt-Text im Medienkatalog sind Vorlagen für künftiges Einfügen. Bereits gespeicherte Seiten behalten ihre kontextbezogenen Texte, Beschriftungen und Bildvarianten. Archivieren blendet Bilder aus der normalen Auswahl aus und ist umkehrbar; bestehende URLs, Seiten und Undo-Stände bleiben funktionsfähig. Es gibt noch keine physische Löschung, Nutzungsübersicht, Ordner oder Katalog-Paginierung.
|
||
|
||
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten bis 480/960/1600 px Breite ohne Hochskalieren. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
|
||
|
||
Dateien liegen unter `MEDIA_DIRECTORY`, standardmäßig `var/media` außerhalb des Webroots. Nur zufällige IDs und bekannte Variantengrößen gelangen über `/media/{id}/{width}` mit festem `image/webp`, `nosniff` und unveränderlichem Langzeitcache nach außen. Erweiterungslose URLs funktionieren auch über die Frontcontroller-Weiterleitung des lokalen PHP-Servers; Pfade mit nicht vorhandenen `.webp`-Dateien wurden dort als statische 404 behandelt. Bild-URLs sind öffentlich, auch vor Veröffentlichung einer Seite und nach Archivieren; der Uploaddialog benennt dies. Die Katalog-API bleibt geschützt. Dies ist keine private Dokumentablage.
|
||
|
||
Schema v3 ergänzt den Container `gallery`, dessen Kinder ausschließlich Bildblöcke sein dürfen. Bestehende Baumaktionen (Auswahl, Verschieben, Löschen, Duplizieren, Undo) gelten auch für Galerieelemente; unpassende Verschiebeziele werden bereits im Modell und zusätzlich vom Server abgewiesen. Bilder dürfen `mediaId`, `sources` und eine Klartext-`caption` enthalten. Quellen für `srcset` werden einzeln auf URL und Breite geprüft. Die Ausgabe verwendet `figure`/`figcaption`, reale Maße, verzögertes Laden und responsive Quellen. Galerie-Spalten und Abstände nutzen dieselben responsiven Tokens wie Zeilen. Keine Lightbox oder Slideshow in diesem Schritt.
|
||
|
||
`layout.effects` erlaubt nur vordefinierte Schatten, Hover-Anheben/Bildzoom sowie Einblenden beim Laden. Effekte gelten geräteübergreifend. Die CSS-Datei wird nur für v3-Seiten eingebunden; es wird kein öffentliches JavaScript ergänzt. Bewegungsregeln gelten ausschließlich bei `prefers-reduced-motion: no-preference`. Inhalte bleiben ohne Animation lesbar; Einblendung ist keine scrollgesteuerte Animation.
|
||
|
||
Der idempotente Konverter unterstützt nun v1/v2 → v3 und verhindert Downgrades. Erst Einfügen einer Galerie/eines Katalogbildes, eine Bildbeschriftung oder Effektänderung hebt den lokalen Entwurf auf v3. Die Umstellung ist Teil derselben Undo-Transaktion; Speichern erzeugt eine neue Revision. v1/v2 bleiben les- und schreibbar. Kein Massendatenupdate. Rückweg über alte Revisionen oder gemeinsames Backup plus passenden Code; ältere v1/v2-Software darf keine v3-Revisionen laden. Die neue Medientabelle darf beim Rückweg nicht unbedacht gelöscht werden.
|
||
|
||
## ADR 010 – Seitenerstellung, Hierarchie und Inhaltstypen (12.09.2026)
|
||
|
||
Seiten konnten bisher nur über die Kommandozeile entstehen, und `page.slug` war websiteweit eindeutig. Damit war weder eine URL-Hierarchie wie `/leistungen/webdesign` noch ein Beitragstyp möglich. Beides gehört in das Datenmodell und wird deshalb jetzt entschieden, solange noch keine Kundeninhalte gespeichert sind; eine spätere Änderung würde eine Migration über echte Kundendaten erzwingen.
|
||
|
||
Migration `20260912000400` ergänzt `page.path`, `page.parent_id`, `page.type` und `page.created_at`. `slug` bleibt das einzelne Adresssegment, `path` ist die vollständige öffentliche Adresse und trägt die websiteweite Eindeutigkeit über den Index `uniq_page_path (website_id, locale, path)`. Der bisherige eindeutige Index über `slug` entfällt: ein Segment muss nur unter seinen Geschwistern eindeutig sein. Die Tiefe ist auf vier Ebenen begrenzt (`PageRepository::MAX_DEPTH`), Beiträge (`type = post`) nehmen keine Unterseiten auf.
|
||
|
||
`parent_id` ist bewusst **ohne** Fremdschlüssel angelegt, und die Indexumstellung passiert nicht über die Doctrine-Schemadifferenz, sondern in `postUp()` beziehungsweise `Version20260912000400::applyIndexes()`. Grund: SQLite baut bei Index- oder Fremdschlüsseländerungen die gesamte Tabelle neu auf (`CREATE TEMPORARY TABLE … DROP TABLE page …`), was am Fremdschlüssel `revision.page_id` scheitert. Mit reinen `ADD COLUMN`-Anweisungen bleibt die Migration auf SQLite und MySQL gleichermaßen ein einfacher, nicht tabellenkopierender Schritt. Die Elternbeziehung wird dafür im `PageRepository` innerhalb derselben Transaktion aufgelöst und geprüft. `path` ist auf Datenbankebene `NULL`-fähig, weil der eindeutige Index vor dem Backfill entsteht und wiederholte `NULL`-Werte in SQLite wie in MySQL nicht kollidieren; die Anwendung schreibt niemals eine Seite ohne Pfad, und `postUp()` bricht ab, falls nach dem Backfill noch ein Pfad fehlt.
|
||
|
||
Anlegen und Löschen laufen über Formulare, nicht über die JSON-API: `POST /admin/pages` benötigt `ROLE_EDITOR`, `POST /admin/pages/{id}/delete` benötigt `ROLE_ADMIN`, beide mit CSRF-Token. Eine neue Seite entsteht als privater Entwurf mit einem gültigen Startdokument (Schema v3, genau eine H1) und wird erst durch bewusstes Veröffentlichen öffentlich. Löschen entfernt alle Revisionen einer Seite, weist aber Seiten mit Unterseiten ab und prüft die erwartete Version, damit keine Inhalte unbemerkt verschwinden. Die reservierten Adressen `admin`, `media`, `assets` und `favicon` sind für Wurzelseiten gesperrt; zusätzlich hält ein Lookahead in der öffentlichen Route die CMS-Pfade aus dem Auffangmuster heraus.
|
||
|
||
Rückweg: Code-Rollback allein genügt nicht, sobald Unterseiten existieren, weil ältere Software `path` und `parent_id` nicht kennt. Rückweg über das vor der Migration erzeugte Backup (`var/backups/before-hierarchy-*.db`) zusammen mit dem passenden Codestand.
|
||
|
||
## ADR 011 – Erhalt unbekannter Blöcke (Dokumentschema v4, 12.09.2026)
|
||
|
||
Bisher wies der Lesevalidator jeden unbekannten Blocktyp ab. Eine Seite mit Bausteinen aus einer deaktivierten oder noch nicht installierten Erweiterung wäre damit nicht mehr ladbar gewesen – der Fallback im Renderer war unerreichbar. AGENTS.md und der Entwicklungsplan verlangen für fehlende Blocktypen aber einen kontrollierten Fallback. Dieser Vertrag wird jetzt festgelegt, bevor es Plugins gibt, weil er sonst nachträglich über bestehende Kundeninhalte eingeführt werden müsste.
|
||
|
||
Ab Dokumentschema v4 gilt ein Typ mit Namensraum – `^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$`, zum Beispiel `acme.slider` – als Baustein einer Erweiterung. Kernblöcke bleiben ohne Punkt; ein unbekannter Typ ohne Namensraum wird weiterhin abgewiesen. Für Plugin-Blöcke prüft der Kern weiterhin Block-ID, Verschachtelungstiefe, Blockanzahl, die Dokumentgröße von 256 KiB sowie dass `props` und `layout` Objekte sind. Ihre `props` prüft er bewusst **nicht**: er kennt das Schema der Erweiterung nicht.
|
||
|
||
Damit ungeprüfte Werte nie in die Ausgabe gelangen, rendert der Kern aus einem solchen Block **nichts**. Öffentlich entsteht nur ein HTML-Kommentar mit ID und Typ; Besucher sehen kein kaputtes Fragment. Nur in der Editorvorschau erscheint ein sichtbarer Platzhalter, damit der Block auffindbar und verschiebbar bleibt. Kinder eines Plugin-Blocks sind erlaubt und werden gespeichert, aber nicht gerendert, weil der Kern ihr Layout nicht kennt. Kern-Designtokens gelten für Plugin-Blöcke nicht, da das Tokenmanifest ihre Typen nicht führt.
|
||
|
||
Im Editor bleiben Plugin-Blöcke auswählbar, verschiebbar, duplizierbar und löschbar; das Eigenschaftenpanel zeigt statt Feldern einen Hinweis. In den Baustein hinein lässt sich nichts einfügen (`canContain` ist false), und `createBlock` kann keinen erzeugen – Plugin-Blöcke entstehen ausschließlich durch die jeweilige Erweiterung oder durch Import.
|
||
|
||
Rückweg: v1 bis v3 bleiben les- und schreibbar, ein Downgrade gibt es nicht. Ältere Software darf keine v4-Revision laden. Dies ist der Erhaltungsvertrag, noch kein Pluginsystem: Registrierung, Hooks und Ressourcenladen folgen in Phase 4.
|
||
|
||
## ADR 012 – Menüs als validierter Baum je Platz (12.09.2026)
|
||
|
||
Die öffentliche Navigation stand fest im Template. Der Pilot kant-it.de braucht aber Ankerziele auf der Startseite (`#dienstleistungen`, `#projekte`, `#kontakt`), externe Ziele wie `hausklar.app` sowie `tel:` und `mailto:` – eine aus der Seitenhierarchie erzeugte Navigation allein hätte dafür nicht gereicht.
|
||
|
||
Migration `20260912000500` legt eine Tabelle `menu` an: ein Menü je Platz (`header`, `footer`) und Sprache, mit Name, Elementbaum als JSON und `version` für dieselbe optimistische Konflikterkennung wie bei Seiten. Menüs sind klein und werden als Ganzes gespeichert; eine eigene Tabelle für Einträge würde Sortierung und Verschachtelung nur umständlicher machen.
|
||
|
||
Ein Eintrag verweist entweder auf eine Seite – optional zusammen mit einem Sprungziel innerhalb dieser Seite – oder auf eine Adresse. **Seitenverweise speichern die Seiten-ID, nie den Pfad.** Der Pfad entsteht erst beim Rendern. Damit folgt ein Menüeintrag einer umbenannten Seite automatisch, und eine gelöschte Seite hinterlässt keinen toten Link.
|
||
|
||
Beim Auflösen werden Einträge übersprungen, deren Seite fehlt oder unveröffentlicht ist: eine gelöschte Seite darf die Navigation nicht zerstören, und ein Entwurf darf nicht über das Menü öffentlich werden. Vorhandene Kinder eines übersprungenen Eintrags rücken nach, statt mit dem Ast zu verschwinden. Der gespeicherte Baum bleibt dabei unangetastet – wird die Seite später veröffentlicht, erscheint ihr Eintrag wieder.
|
||
|
||
Erlaubte Adressen sind HTTPS, lokale Pfade sowie `mailto:` und `tel:`; `tel:` und `mailto:` stehen hier, weil sie auf der Pilotseite echte Navigationsziele sind. `http:`, protokollrelative Adressen und `javascript:` werden abgewiesen. Sprungziele sind auf `^[a-zA-Z][a-zA-Z0-9_-]{0,64}$` begrenzt. Grenzen: 60 Einträge, zwei Ebenen.
|
||
|
||
Die Verwaltung arbeitet mit einer flachen, nummerierten Zeilenliste statt mit Drag-and-drop: Reihenfolge über die Position, Verschachtelung über die Ebene. Das funktioniert vollständig ohne JavaScript und ist mit der Tastatur bedienbar. Untermenüs in der Ausgabe nutzen `details`/`summary` und brauchen deshalb ebenfalls kein öffentliches JavaScript.
|
||
|
||
## ADR 013 – Testcache wird je Lauf verworfen (12.09.2026)
|
||
|
||
Das Testkernel läuft mit `debug = false`. Symfony prüft dann weder den kompilierten Container noch die kompilierten Twig-Vorlagen auf Aktualität. Zweimal hat das während der Entwicklung ein falsches Ergebnis erzeugt: eine geänderte Vorlage wurde aus dem Cache beantwortet und ein Test bestand fälschlich, später schlug ein geänderter Konstruktor mit einem `TypeError` aus einem veralteten Container fehl.
|
||
|
||
`tests/bootstrap.php` löscht deshalb `var/cache/test` einmal pro Lauf. Das kostet rund eine Sekunde und schließt diese Fehlerklasse aus. Zusätzlich lädt Twig in der Testumgebung Vorlagen automatisch neu, und das Login-Throttling zählt dort in einem Array-Cache statt in einem dauerhaften Pool – beide Einstellungen betreffen ausschließlich die Testumgebung, die geprüfte Drosselung selbst bleibt aktiv.
|
||
|
||
## ADR 014 – Beiträge: Metadaten als Seitenspalten, Beitragsliste als Renderer-Block (12.09.2026)
|
||
|
||
Beitragsdatum, Autor, Auszug und Beitragsbild beschreiben die Seite, nicht ihren Inhalt. Sie liegen deshalb als Spalten auf `page` und nicht im Blockdokument. Das hält Dokumentschema und Editor unverändert, und vor allem kann das Archiv in SQL sortieren und paginieren, statt für jede Auflistung sämtliche Dokumente zu dekodieren.
|
||
|
||
`published_at` wird beim Veröffentlichen gesetzt, falls noch keins gepflegt ist. Es ist damit kein zweiter Wahrheitsort, sondern die eine gepflegte Angabe; die Sortierung stützt sich auf den Index `idx_page_published_at`. Seiten werden nie gestempelt – nur Beiträge.
|
||
|
||
Kategorien und Schlagwörter teilen sich die Tabelle `term` mit einem Feld `kind`, weil sie sich bis auf ihr Archiv identisch verhalten. Namen werden beim Speichern auf ihren Slug abgebildet und bei Bedarf angelegt; die Zuordnung in `page_term` wird bei jedem Speichern ersetzt statt ergänzt. Beim Löschen einer Seite verschwinden ihre Zuordnungen, die Begriffe selbst bleiben: sie sind wiederverwendbare Etiketten, kein Inhalt eines einzelnen Beitrags. Ein leer gewordenes Archiv antwortet deshalb mit HTTP 200 und einer leeren Liste, nicht mit 404.
|
||
|
||
**Der Block `postlist` (Dokumentschema v5) speichert keine Beiträge, sondern nur Anzahl und Filter.** Die Beiträge stellt der Controller beim Ausliefern zusammen und übergibt sie getrennt an die Vorlage. Ein gespeichertes Dokument kann damit keinen Beitrag beanspruchen, den es nicht zeigen darf – insbesondere keinen unveröffentlichten. Dasselbe gilt in der Editorvorschau.
|
||
|
||
Archive liegen unter `/beitraege`, `/kategorie/{slug}` und `/schlagwort/{slug}`, der Feed unter `/feed.xml`; alle vier Segmente sind für Wurzelseiten reserviert und aus dem öffentlichen Auffangmuster ausgenommen. Paginierung über `?seite=N`, mit `rel="prev"`/`rel="next"` und Begrenzung auf den gültigen Bereich statt einer leeren Seite bei zu großen Zahlen.
|
||
|
||
Die Beitragsdaten werden über ein Formular in der Seitenübersicht gepflegt, nicht im Editor: es sind Seitendaten, kein Blockinhalt, und so bleibt die Bedienung ohne JavaScript möglich. Das Formular nutzt dieselbe optimistische Konflikterkennung wie jeder andere Schreibzugriff auf eine Seite.
|
||
|
||
## ADR 015 – Blöcke für die Pilotseite (Dokumentschema v6, 12.09.2026)
|
||
|
||
Die Startseite von kant-it.de besteht aus Abschnittsarten, die sich aus Sektion, Zeile, Spalte, Überschrift und Text nicht sinnvoll nachbauen lassen. Schema v6 ergänzt deshalb zehn Blöcke: `iconcard`, `stat`, `quote`, `logos`, `cta`, `contact`, `divider`, `spacer`, `accordion` und `accordionitem`.
|
||
|
||
**Symbole liegen als Inline-SVG aus einer festen Liste im Template.** Kein Symbolschriftsatz, keine externe Anfrage, kein zusätzliches Stylesheet. Der Validator lässt nur Namen aus `DocumentValidator::ICONS` zu; die Liste im Editor spiegelt dieselben Werte, verbindlich ist der Server.
|
||
|
||
`contact` gibt Telefon und E-Mail als `tel:`- und `mailto:`-Verweise aus. Die Telefonnummer wird für das Linkziel von Leerzeichen und Trennzeichen befreit, bleibt aber lesbar dargestellt. Ein Kontaktblock ohne jede Angabe wird abgewiesen, statt ein leeres Element zu erzeugen.
|
||
|
||
`accordion` und `accordionitem` nutzen `details`/`summary` und funktionieren damit ohne öffentliches JavaScript und mit der Tastatur. Ein Akkordeon enthält ausschließlich Einträge, ein Eintrag enthält gewöhnliche Inhalte – und ein Eintrag außerhalb eines Akkordeons wird abgewiesen, auch auf oberster Dokumentebene. Diese Prüfung sitzt deshalb an der Typprüfung des Blocks und nicht nur beim Durchlaufen der Kinder; ein Test hatte gezeigt, dass die Regel sonst genau am Wurzelknoten nicht greift.
|
||
|
||
**Tabs sind bewusst nicht umgesetzt.** Ohne JavaScript gibt es dafür keine zugängliche Lösung: eine CSS-Variante über Radiobuttons ist zwar bedienbar, wird von Screenreadern aber als Auswahlfeld angekündigt und nicht als Tabliste. Da Barrierefreiheit im Plan Vorrang vor Funktionsumfang hat, ist ein Akkordeon hier die ehrlichere Antwort auf denselben Bedarf. Tabs bleiben offen, bis entweder gezielt JavaScript für die Verwaltung dieses Musters vorgesehen ist oder ein tragfähiger Ansatz ohne feststeht.
|
||
|
||
`logos` verhält sich wie `gallery`: ein Container, der ausschließlich Bilder aufnimmt. Die Beschränkung steht im Validator und wird im Editormodell (`ONLY_CHILD`) gespiegelt, damit die Oberfläche gar nicht erst etwas anbietet, was der Server ablehnen würde.
|
||
|
||
Die neuen Typen sind im Tokenmanifest für Außenabstände eingetragen, teils auch für Ausrichtung, Ecken, Spalten und Innenabstand. Die generierte `design.css` ändert sich dadurch nicht, weil sie je Wert und nicht je Blocktyp erzeugt wird.
|
||
|
||
## ADR 016 – Tabs als fortschreitende Verbesserung (Dokumentschema v7, 12.09.2026)
|
||
|
||
ADR 015 hatte Tabs zurückgestellt, weil es ohne JavaScript keine zugängliche Lösung dafür gibt. Umut hat entschieden, Tabs mit JavaScript umzusetzen. Damit wird eine Grundregel des Plans berührt: „der eigentliche Seiteninhalt benötigt kein Browser-JavaScript“. Diese Regel bleibt gültig, und die Umsetzung hält sie ein.
|
||
|
||
**Der Server liefert vollständigen Inhalt.** Ein Tab-Block wird als Ankerliste gefolgt von allen Panels ausgegeben, jedes mit eigener Überschrift. Ohne JavaScript ist damit jeder Inhalt lesbar, verlinkbar und über die Tastatur erreichbar – die Seite ist nicht kaputt, sondern nur weniger kompakt. Kein Panel wird serverseitig versteckt; Verstecken ist ausschließlich Aufgabe des Skripts.
|
||
|
||
`public/assets/tabs.js` ergänzt anschließend `tablist`, `tab` und `tabpanel`, die Auswahl per Pfeiltasten, Home und Ende sowie `aria-selected`. Die Überschrift eines Panels wandert dabei in eine visuell versteckte Klasse, weil der Tab selbst das Panel benennt. Ein Sprungziel aus einer fremden Verlinkung wählt das passende Panel aus, statt es verborgen zu lassen.
|
||
|
||
**Die strenge Sicherheitsrichtlinie bleibt die Voreinstellung.** Öffentliche Seiten senden weiterhin `script-src 'none'`. Nur eine Seite, die tatsächlich einen Tab-Block enthält, sendet `script-src 'self'` und bindet die eine externe Datei ein; Inline-Skript ist nie erlaubt. Ohne Tabs auf der Seite ändert sich nichts, auch nicht auf der 404-Seite, in Archiven oder im Feed. Der Controller ermittelt dafür beim Ausliefern, ob das Dokument den Blocktyp enthält.
|
||
|
||
Die Editorvorschau führt kein JavaScript aus (siehe QUALITY). Tabs erscheinen dort deshalb als untereinanderliegende Abschnitte. Das steht so im Eigenschaftenpanel, damit der Unterschied zur veröffentlichten Seite nicht als Fehler missverstanden wird.
|
||
|
||
Ein neuer Tab-Block bringt zwei Tabs mit: ein einzelner Tab ist keine Tabgruppe, und das Skript verbessert erst ab zwei Einträgen. Ein Tab außerhalb einer Tabgruppe und eine Tabgruppe mit anderem Inhalt werden abgewiesen, auch auf oberster Dokumentebene.
|
||
|
||
## ADR 017 – Formulare (Dokumentschema v8, 12.09.2026)
|
||
|
||
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
|
||
|
||
**Kein CSRF-Token und keine Sitzung auf dem öffentlichen Weg.** Symfonys CSRF-Schutz braucht eine Sitzung, und eine Sitzung bedeutet ein Cookie auf jeder öffentlichen Seite; das vermeidet das Projekt bewusst. Die Route ist anonym und ändert nichts, was einem Besucher gehört: eine gefälschte Anfrage könnte höchstens eine Nachricht senden, und genau dagegen wirkt der Spamschutz. Die Verwaltung der Formulardefinitionen bleibt dagegen sitzungsgebunden und CSRF-geschützt.
|
||
|
||
**Spamschutz ohne Dienst Dritter**, in drei Stufen: ein Honeypot-Feld außerhalb von Sichtbarkeit, Tabreihenfolge und Hilfstechnik; ein **signierter Zeitstempel** im Formular, der zu schnelle und zu alte Absendungen erkennt, ohne dafür etwas zu speichern; und ein Limit von fünf Absendungen je Absender und Stunde. Ein erkannter Bot bekommt dieselbe Antwort wie ein Erfolg, damit er nichts über die Prüfung lernt – gespeichert und gesendet wird nichts.
|
||
|
||
**Die Absenderadresse wird nie gespeichert**, nur ein mit dem Anwendungsgeheimnis gebildeter Hash. Das reicht für das Limit und erlaubt keinen Rückschluss auf die Herkunft. Der Zeitpunkt der Einwilligung wird getrennt festgehalten.
|
||
|
||
Ablauf ohne JavaScript: eine fehlerhafte Absendung rendert dieselbe Seite erneut mit HTTP 422, allen Eingaben und einer Meldung je Feld, verbunden über `aria-describedby` und `aria-invalid`; eine gültige Absendung wird gespeichert, versendet und mit HTTP 303 auf die Seite zurückgeleitet, sodass ein Neuladen nichts doppelt sendet.
|
||
|
||
**Gespeichert wird vor dem Versand.** Ein kaputter Mailversand verliert damit keine Anfrage: die Einsendung bleibt in der Verwaltung sichtbar und als nicht versendet gekennzeichnet. Als Transport ist `sendmail` voreingestellt, weil das gewöhnliches PHP-Hosting anbietet; per `MAILER_DSN` lässt sich SMTP einstellen.
|
||
|
||
**Dateiuploads sind nicht enthalten.** Anhänge brauchen einen eigenen, nicht öffentlichen Ablageort mit Zugriffsschutz, eine Typprüfung und eine Entscheidung über den Versand als Anhang – eine eigene Sicherheitsfläche, die nicht nebenbei entsteht. Das Formular des Piloten hat kein Dateifeld. Der Punkt bleibt im Ausbauplan offen.
|
||
|
||
Beim Schreiben der Tests fiel ein echter Fehler auf: das Stundenfenster des Limits wurde mit der lokalen Uhrzeit gebildet, die Zeitstempel der Einsendungen aber in UTC. Bei einer Uhr vor UTC hätte das Limit nie gegriffen. Beide Seiten rechnen jetzt in UTC.
|
||
|
||
## ADR 018 – SEO, Weiterleitungen und strukturierte Daten (Dokumentschema v9, 12.09.2026)
|
||
|
||
Migration `20260912000800` ergänzt `page.robots`, `page.og_media_id`, `website.settings` und die Tabelle `redirect`.
|
||
|
||
**Alles Maschinenlesbare wird aus dem veröffentlichten Bestand abgeleitet, nichts davon separat gepflegt.** Die Sitemap listet ausschließlich veröffentlichte Seiten mit `robots = index`, dazu Beitragsübersicht und Begriffsarchive, und zwar nur solange diese etwas enthalten. Eine nicht indexierbare Seite verschwindet damit gleichzeitig aus der Sitemap und bekommt ein `noindex`-Meta – die beiden Aussagen können nicht auseinanderlaufen.
|
||
|
||
Strukturierte Daten entstehen ebenfalls nur aus gepflegten Werten: `Organization` beziehungsweise `LocalBusiness` aus den Unternehmensdaten, `Article` aus den Beitragsmetadaten, `BreadcrumbList` aus der Seitenhierarchie. Solange nichts gepflegt ist, wird **kein** Block ausgegeben; ein leeres `Organization` wäre Rauschen, keine Angabe. Die Auszeichnung steht als `application/ld+json` im Kopf – ein Datenblock, kein Skript, weshalb `script-src 'none'` davon unberührt bleibt.
|
||
|
||
Weiterleitungen werden vor dem 404 geprüft, aber **nach** der Seitensuche: eine vorhandene Seite gewinnt immer gegen eine gleichnamige Weiterleitung, sonst könnte ein alter Eintrag eine neue Seite unerreichbar machen. 301 braucht ein Ziel, 410 nicht. Ketten sind ausgeschlossen: zeigt das Ziel selbst weiter, wird der Eintrag abgewiesen statt einen zusätzlichen Umweg oder eine Schleife zu erzeugen.
|
||
|
||
`robots.txt` erlaubt zusätzliche Zeilen aus den Einstellungen, aber nur Kommentare und einfache Anweisungen nach einem festen Muster. Eine zweite `Sitemap:`-Zeile oder Markup wird herausgefiltert, damit ein Eintrag in der Verwaltung die Datei nicht umdeuten kann.
|
||
|
||
`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.
|
||
|
||
## ADR 020 – Direktbearbeitung, Kontrastwarnung und Gestaltungsvorlagen (12.09.2026)
|
||
|
||
**Inline-Textbearbeitung ohne Skript in der Vorschau.** Der Vorschau-Iframe ist mit `sandbox="allow-same-origin"` eingebettet: er führt kein eigenes JavaScript aus, aber der Editor kann sein Dokument erreichen. Die Bearbeitung läuft deshalb vollständig vom Editor aus – das Element wird auf `contenteditable="plaintext-only"` gesetzt, Tastatureingaben gehen direkt ins Modell, und die Vorschau wird währenddessen **nicht** neu geladen, weil das den Cursor zerstören würde. Erst beim Beenden folgt eine einzige Aktualisierung. Damit bleibt es dabei, dass ausgelieferte Seiten kein Editor-JavaScript enthalten.
|
||
|
||
Welche Blöcke direkt bearbeitbar sind, steht in einer Tabelle: Überschrift und Text am Block selbst, dazu das Zitat, der Titel eines Aufruf-Bands, einer Symbolkarte, eines Akkordeon-Eintrags, eines Tabs und die Zahl einer Kennzahl. Bei einspaltigen Feldern beendet Enter die Bearbeitung, bei mehrzeiligen nicht. Eingaben werden über einen Merge-Schlüssel zu einem einzigen Undo-Schritt zusammengefasst, wie das Tippen im Eigenschaftenpanel. Neben dem Doppelklick gibt es eine Schaltfläche im Panel, damit der Ablauf auch ohne Maus erreichbar ist.
|
||
|
||
**Kontrast ist eine Warnung, kein Verbot.** Der Plan verlangt eine Kontrastprüfung, nicht Bevormundung. Das Panel berechnet das Verhältnis zwischen wirksamer Text- und Hintergrundfarbe nach WCAG 2.1 – Palettenverweise werden dafür aufgelöst – und meldet drei Stufen: ab 4,5:1 in Ordnung, ab 3:1 nur für großen Text, darunter zu wenig. Gespeichert wird trotzdem, was eingestellt wurde.
|
||
|
||
**Gestaltungsvorlagen liegen im Theme**, nicht im Dokument, denn sie gelten seitenübergreifend. Eine Vorlage wird mit demselben Compiler geprüft wie ein Blockstil, sodass über diesen Weg kein Wert hereinkommt, für den ein Block abgewiesen würde. Das Speichern der Palette lässt die Vorlagen unberührt und umgekehrt. Zusätzlich lässt sich eine Gestaltung innerhalb der Sitzung von Block zu Block kopieren; beides ersetzt die Gestaltung des Ziels vollständig, statt sie zu vermischen – ein halb übernommener Look wäre schwerer zu verstehen als ein ganzer.
|
||
|
||
## ADR 021 – Die erzeugten Stile in der Editorvorschau (12.09.2026)
|
||
|
||
Beim Wiederherstellen des Browsertests kam heraus, dass **die frei gestalteten Werte in der Editorvorschau überhaupt nicht wirkten**. Der Grund liegt an einer Stelle, die man ohne Browserlauf nicht sieht: ein `srcdoc`-Iframe erbt die Sicherheitsrichtlinie des einbettenden Dokuments. Die Verwaltung verbietet Inline-Styles, also wurde das erzeugte `<style>` in der Vorschau stillschweigend verworfen. Ausgeliefert wurde es korrekt, nur die Vorschau log.
|
||
|
||
Die Vorschau liefert das Stylesheet deshalb als **inertes `<template>`**, das keine Richtlinie verletzt, und der Editor wendet es als *constructed stylesheet* über `adoptedStyleSheets` an. Das ist keine Quelle im Sinne der Richtlinie, braucht also weder `unsafe-inline` noch einen Hash, und die Konsole bleibt sauber. Öffentliche Seiten bekommen weiterhin ein echtes `<style>`, erlaubt über seinen eigenen Hash.
|
||
|
||
Die Verwaltung erlaubt zusätzlich die Schrifthosts, damit die Vorschau dieselbe Schrift zeigt wie die veröffentlichte Seite. Alles andere bleibt so eng wie auf der öffentlichen Seite.
|
||
|
||
Zwei weitere Fehler derselben Ursache wurden dabei gefunden: die Einrückung der Unterseiten in der Seitenliste lief über ein `style`-Attribut und wurde ebenfalls verworfen – die Hierarchie sah immer flach aus. Sie nutzt jetzt Klassen. Und das Theme setzte **alle** Links auf die Akzentfarbe und überschrieb damit die kontextabhängigen Farben; auf dunklem Grund ergab das 3,08:1. Die Regel gilt jetzt nur für Links in Textblöcken, und die Voreinstellung für Linkfarbe ist ein dunklerer Verwandter des Akzents, der auf allen hellen Tönen über 4,5:1 liegt.
|
||
|
||
Bewegungseffekte bleiben als eigener Abschnitt im Panel für jeden Block erhalten: Schatten-, Hover- und Einblendungsvorlagen sind Keyframes und lassen sich mit den freien Deklarationen nicht ausdrücken. Die alten Tokenwerte erscheinen dagegen nur noch dort, wo eine Seite sie schon trägt.
|
||
|
||
## ADR 022 – Seiten verwalten: Verschieben, Papierkorb, Fassungen, Zeitplan (12.09.2026)
|
||
|
||
Migration `20260912000900` ergänzt `page.publish_at`, `page.deleted_at` und `revision.seq`.
|
||
|
||
**Verschieben nimmt den ganzen Teilbaum mit und hinterlässt Weiterleitungen.** Der Vorgang läuft in einer Transaktion: ein halb verschobener Teilbaum hätte Seiten unerreichbar gemacht. Vor der ersten Änderung werden alle betroffenen Adressen eingesammelt, auf Kollisionen und auf die Tiefengrenze geprüft; erst danach wird geschrieben und für jede alte Adresse ein 301 vermerkt. Eine Seite unter eine ihrer eigenen Unterseiten zu verschieben wird abgewiesen, weil das den Teilbaum abtrennen würde.
|
||
|
||
**Der Papierkorb ersetzt das sofortige Löschen für die Editorrolle.** Eine Seite im Papierkorb behält alle Fassungen, antwortet öffentlich mit 404 und verschwindet aus Navigation, Archiven, Feed, Sitemap, `llms.txt` und der Editorliste. Unterseiten gehen mit, weil sie ohne ihr Elternteil in der Adresse nicht erreichbar wären; beim Wiederherstellen muss deshalb zuerst das Elternteil zurück. Das endgültige Löschen mit allen Fassungen bleibt der Administratorrolle.
|
||
|
||
**Eine Fassung wiederherzustellen überschreibt nichts.** Das alte Dokument wird erneut gespeichert und damit eine neue Fassung, sodass der bisherige Stand in der Liste bleibt.
|
||
|
||
`revision.seq` behebt einen echten Fehler, den erst ein Test gezeigt hat: `created_at` hat Sekundengenauigkeit, und mehrere Speichervorgänge fallen in dieselbe Sekunde. Die Reihenfolge im Verlauf war dadurch **nicht eindeutig** – sie richtete sich nach der zufälligen Revisions-ID. Jetzt trägt jede Fassung einen je Seite streng aufsteigenden Zähler; die Migration füllt ihn aus den vorhandenen Zeitstempeln.
|
||
|
||
**Geplante Veröffentlichung** setzt `publish_at`. Ein Cron-Aufruf von `cms:publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt. Ohne Cron bleibt die Seite ein Entwurf – die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
|
||
|
||
**Eine frei wählbare Startseite ist nicht umgesetzt.** Der Pfad `home` gilt an elf Stellen als Wurzel der Website – öffentliche Routen, Canonical, Sitemap, `llms.txt`, Menüauflösung. Das zu einer Einstellung zu machen ist ein eigener Schritt; bis dahin behält die Startseite ihre Adresse, statt dass die Hälfte des Systems der Änderung folgt und die andere nicht.
|
||
|
||
## ADR 023 – Frei wählbare Startseite (12.09.2026)
|
||
|
||
ADR 022 hatte das zurückgestellt, weil der Pfad `home` an elf Stellen als Wurzel galt. Statt die elf Stellen einzeln zu ändern, gibt es jetzt **eine** Quelle: `SiteSettings::homePath()` sagt, welche Seite unter `/` antwortet, und `SiteSettings::publicPath()` bildet einen gespeicherten Pfad auf seine öffentliche Adresse ab. Öffentliche Routen, Canonical, Sitemap, `llms.txt`, Menüauflösung, Beitragslisten und der Formularrücksprung benutzen diese beiden Methoden; das Literal kommt im Anwendungscode nicht mehr vor.
|
||
|
||
Die gewählte Seite muss existieren, veröffentlicht und nicht im Papierkorb sein – eine Wurzel, die auf nichts zeigt, würde jeden Besuch mit 404 beantworten. Ihre eigene Adresse leitet dauerhaft auf `/`, damit derselbe Inhalt nicht unter zwei Adressen liegt. Solange eine Seite die Wurzel ist, lässt sie sich weder umbenennen noch in den Papierkorb legen; die vorherige Wurzel wird dadurch zu einer gewöhnlichen Seite und ist wieder frei verschiebbar.
|
||
|
||
## ADR 024 – Der lokale Server braucht ein Routerskript (12.09.2026)
|
||
|
||
`/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` waren **lokal nie erreichbar**, obwohl jeder Kernel-Test sie bestanden hat. Der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad **mit Dateiendung** mit 404, ohne den Frontcontroller zu fragen. Dasselbe hatte schon bei den `.webp`-Medien zugeschlagen und war damals durch endungslose Routen umgangen worden – hier geht das nicht, weil die Dateinamen Teil des Standards sind.
|
||
|
||
`bin/dev-router.php` bildet nach, was `public/.htaccess` auf echtem Hosting tut: vorhandene Dateien direkt, alles andere an `public/index.php`. Start- und CI-Befehl verwenden es. **Und der Browsertest prüft diese vier Adressen jetzt**, denn der Grund, warum es so lange unbemerkt blieb, war schlicht, dass niemand sie über HTTP aufgerufen hat.
|
||
|
||
## ADR 025 – Barrierefreiheitsbefunde: Code trennen von Inhalt (12.09.2026)
|
||
|
||
Ein Kontrastfehler hat zwei sehr verschiedene Ursachen, und sie brauchen verschiedene Antworten. Kommt er aus dem Theme oder einem Stylesheet, ist es ein Defekt des Systems und der Lauf muss fehlschlagen – genau das hat die Linkfarbe aufgedeckt, die jeden dunklen Abschnitt überschrieb. Kommt er aus einer Farbe, die jemand im Editor an einem einzelnen Block gesetzt hat, ist es ein redaktionelles Thema: im Code nicht behebbar, und einen Codewechsel dafür rot zu färben würde nur dazu führen, dass die Prüfung irgendwann abgeschaltet wird.
|
||
|
||
`scripts/browser-check.mjs` unterscheidet deshalb: ein Kontrastverstoß, dessen Element eine Regel im erzeugten Seiten-Stylesheet hat, wird als redaktionelle Feststellung bei **jedem** Lauf mit Seite, Farbe und Verhältnis ausgegeben und in `reports/browser.json` geschrieben. Alles andere bleibt ein Defekt und bricht den Lauf ab. Stillschweigend durchgelassen wird nichts.
|
||
|
||
## ADR 026 – Kopfbereich, Fußbereich und verknüpfte Sektionen (Dokumentschema v11, 12.09.2026)
|
||
|
||
Migration `20260912001000` legt die Tabelle `partial` an: Blockdokumente, die keine Seiten sind. Kopf- und Fußbereich gibt es je einmal, wiederverwendbare Sektionen beliebig viele. Sie nutzen dieselben Blockverträge, denselben Validator und **denselben Editor** wie eine Seite – der Editor nimmt seine Schnittstelle aus einem Datenattribut, sodass es keine zweite Umsetzung gibt.
|
||
|
||
Ein Unterschied bleibt: eine Seite braucht **genau eine** Hauptüberschrift, ein geteilter Bereich **keine**. Sonst hätte jede Seite, die ihn trägt, zwei. Der Validator kennt dafür einen Schalter, und beide Fälle sind geprüft.
|
||
|
||
Geteilte Bereiche haben **keinen Entwurf und keine Veröffentlichung**. Sie erscheinen auf jeder Seite; ein zweiter Stand wäre nur die Frage, welche Seite welchen sieht. Gespeichert heißt live, und der Editor lässt die Veröffentlichen-Schaltfläche für sie weg. Optimistische Konflikterkennung gilt wie überall.
|
||
|
||
**Verknüpft statt kopiert.** Der Block `partialref` speichert nur die Adresse der Sektion; ihre Blöcke bleiben im Bereich. Eine Änderung erreicht damit jede Seite, die sie einbindet – das ist der Unterschied zu den Abschnittsvorlagen, die weiterhin *kopieren*. Beides hat seinen Platz, und der Plan verlangt genau diese Unterscheidung. Zyklen werden beim Speichern abgewiesen, die Verschachtelung ist auf drei Ebenen begrenzt, und eine entfernte Sektion hinterlässt einen HTML-Kommentar statt einer Lücke; im Editor erscheint ein Platzhalter. Der Verweis bleibt gespeichert, sodass eine wiederhergestellte Sektion sofort wieder erscheint.
|
||
|
||
Damit ein Kopfbereich überhaupt frei baubar ist, gibt es einen Block `menu`: er zeigt einen Menüplatz, dessen Einträge weiterhin in der Menüverwaltung liegen. Die Marke ist ein gewöhnlicher Text- oder Bildblock. Im Template steht keine Navigation und keine Marke mehr.
|
||
|
||
**Ein echter Fehler kam dabei heraus, der nicht nur den eingesetzten Fußbereich betraf:** die Token-CSS war für die flache Struktur Sektion → Text geschrieben. `.block-column>.block-text:first-child` setzt eine Farbe für helle Hintergründe und gewinnt auf Spezifität gegen `.tone-ink .block-text`. Text in einer Spalte auf dunklem Grund war damit unlesbar – gemessen 1,87:1. Zwei gezielte Selektoren gleicher Spezifität, später in der Datei, stellen das richtig; helle Farbwelten bleiben unberührt.
|
||
|
||
## ADR 027 – Formatierter Text (Dokumentschema v12, 12.09.2026)
|
||
|
||
Textblöcke dürfen ab v12 Auszeichnung tragen: fett, kursiv, unterstrichen, durchgestrichen, Code, hervorgehoben, hoch- und tiefgestellt, Absätze, Listen und Links. Die Liste steht in `RichText::ALLOWED`.
|
||
|
||
**Die Entscheidung, was erlaubt ist, fällt auf dem Server beim Speichern** – nicht im Editor und nicht beim Rendern. Zwei Gründe: was in der Datenbank landet, ist damit schon sicher, also kann kein Renderer das Filtern vergessen; und eine manipulierte Anfrage kann nichts speichern, was der Editor nicht anbietet. Der Editor ist ein Vorschlag, der Server ist die Instanz.
|
||
|
||
Nicht erlaubte Elemente werden **ausgepackt, nicht gelöscht**: der Text bleibt, nur die Auszeichnung geht. Ein eingefügtes Word-Dokument verliert seine Gestaltung, nicht seinen Inhalt. Ausnahme sind `script`, `style`, `iframe`, `object`, `embed`, `template`, `noscript`, `svg` und `math` – sie werden samt Inhalt entfernt, weil Auspacken ihren Code als Text sichtbar machen würde. Attribute fallen restlos weg, außer `href` an `a`, und auch das nur nach derselben Adressprüfung wie überall; ein unbrauchbares Ziel macht aus dem Link Text. Externe Links bekommen `rel="noopener"`.
|
||
|
||
Ausgegeben wird formatierter Text **nur ab v12**, und dann unescaped in einem `div` statt einem `p` – die Positivliste enthält Absätze und Listen, die in einem `p` nicht stehen dürfen. Ältere Dokumente behalten Klartextsemantik und werden weiterhin maskiert; der bestehende XSS-Test für v1 gilt unverändert. Ein Dokument wechselt erst auf v12, wenn jemand einen Textblock formatiert bearbeitet.
|
||
|
||
Zeilenumbrüche werden in beiden Zweigen zu `<br>`. Vorher behielt ein Text, der zufällig ein `<` enthielt, echte Zeilenumbrüche, während alles andere `<br>` benutzte – zwei Darstellungen desselben Umbruchs, die beim nächsten CSS-Wechsel auseinandergelaufen wären. Innerhalb von `code` bleiben Umbrüche unangetastet.
|
||
|
||
Ein Textblock, der nach dem Filtern leer ist, wird abgewiesen – dieselbe Regel wie für einen von Hand geleerten Textblock. Wer nur ein verbotenes Element einfügt, bekommt also eine Meldung statt eines stillschweigend leeren Blocks.
|
||
|
||
Im Editor gibt es zwei Sichten auf denselben Wert: eine Formatierungsansicht mit Werkzeugleiste und eine HTML-Ansicht mit der Auszeichnung. Eingefügter Inhalt wird als reiner Text übernommen. Die Werkzeugleiste nutzt `execCommand`; das ist abgekündigt, aber das Einzige, was alle Browser für diese Aufgabe umsetzen, und da der Server ohnehin normalisiert, kann eine Browsereigenheit nichts Unerwartetes speichern.
|
||
|
||
## ADR 028 – Eigenes CSS und eigenes Skript je Element (Dokumentschema v13, 12.09.2026)
|
||
|
||
Umut wollte an jedem Element eigenes CSS **und** eigenes Skript setzen können. Beides ist umgesetzt, aber nicht gleich behandelt, weil beides sehr Unterschiedliches anrichten kann.
|
||
|
||
**Eigenes CSS** wird von `CustomCode` geparst und selbst wieder ausgeschrieben, mit einem Selektor, den diese Klasse setzt und der nie aus der Eingabe stammt. Akzeptiert werden Eigenschaften für das Element und verschachtelte Regeln, die mit `&` beginnen – `&:hover`, `& > p`, `& .block-text`. Weil die Ausgabe neu serialisiert wird, kann eine überzählige `}` den Block nicht verlassen. Abgewiesen werden @-Regeln, Backslash-Escapes, `expression()` und jede Adresse, die nicht mit einem einzelnen `/` oder `https://` beginnt. Fremde Selektoren gibt es nicht. Das darf jeder mit Editorrolle: das Ergebnis kann hässlich sein, aber keinen Code ausführen.
|
||
|
||
**Eigenes Skript ist etwas anderes.** Es ist beliebiges JavaScript auf der veröffentlichten Website; wer es setzen darf, kann Code im Browser jedes Besuchers und jedes Administrators ausführen. Das lässt sich nicht entschärfen, nur eingrenzen:
|
||
|
||
- **Nur Administratoren.** Durchgesetzt wird das beim Schreiben, indem die Skripte der eingehenden Fassung mit den gespeicherten verglichen werden. Ein Editor kann eine Seite mit Skript also weiter bearbeiten und speichern, aber das Skript nicht anfassen – auch nicht über eine selbst gebaute Anfrage. Im Panel ist das Feld für ihn nur lesbar, was dieselbe Regel bloß sichtbar macht.
|
||
- **Als eigene Datei, nicht inline.** `/assets/seite/{id}.js` liefert die Skripte einer **veröffentlichten** Seite, jedes in einer eigenen Closure mit bereits gesuchtem `element` und einem `try`/`catch`, damit ein Fehler die anderen nicht mitnimmt. Ein Skript aus einem Entwurf wird nicht ausgeliefert.
|
||
- **Die strenge Richtlinie bleibt die Voreinstellung.** Nur eine Seite, die tatsächlich ein eigenes Skript trägt, sendet `script-src 'self'`. Jede andere behält `script-src 'none'`. Inline-Skript ist nirgends erlaubt.
|
||
- **In der Editorvorschau wird nichts ausgeführt.** Die Vorschau soll das Layout zeigen, nicht Code laufen lassen, der die Seite unter den Händen des Bearbeiters verändert.
|
||
|
||
Zwei echte Löcher kamen beim Testen heraus. `& .block-text` wurde zu `#id.block-text`, weil das führende Leerzeichen wegtrimmt wurde – aus dem Nachfahren wurde derselbe Knoten, die Regel traf das falsche Element. Und `url(//fremd.example/x.png)` kam durch, weil die Prüfung jedes `/` erlaubte: eine protokollrelative Adresse hätte über das Schema der Seite von Dritten geladen. Beides ist behoben und durch Tests abgedeckt.
|
||
|
||
## ADR 029 – Verwaltung: ein Rahmen statt sieben Kopfzeilen (12.09.2026)
|
||
|
||
Umuts Auftrag war „backend komplett neugestalten, einfacher schöner Bedienbarkeit komplett einfach“, mit ausdrücklicher Gestaltungsfreiheit. Das Problem war weniger die Optik als die Orientierung: jede Verwaltungsseite trug ihre eigene Kopfzeile und ihre eigene, unvollständige Liste von Links auf die anderen Seiten – die Seitenübersicht verlinkte sieben Bereiche, das Menü fünf, die Fassungen drei. Wo man war, stand nirgends.
|
||
|
||
**Ein Rahmen für alle.** `templates/admin/shell.html.twig` trägt die Navigation, die Flash-Meldungen und die Kopfzeile mit Titel, einem Satz Erklärung und Platz für die Hauptaktion. Jede Verwaltungsseite füllt nur noch `heading`, `lead`, `actions` und `content`. Die Navigationsliste steht einmal da; die aktive Seite ist über `aria-current="page"` markiert, also auch ohne Farbe erkennbar. Auf schmalen Fenstern wird die Seitenleiste zu einer waagerecht scrollbaren Leiste – ohne JavaScript, weil die Verwaltung sonst nichts davon braucht.
|
||
|
||
**Eine Zeile je Seite statt einer überladenen Karte.** Vorher hing an jeder Seite eine Reihe von sechs Aufklappfeldern, die als absolut positionierte Kästen über den Rand der Karte hinausragten. Jetzt zeigt die Zeile, was man täglich braucht – Titel, Adresse, Zustand, „Bearbeiten“, „Fassungen“ – und ein einziges Aufklappfeld „Einstellungen“ öffnet darunter, an seinem Platz, die Abschnitte Seitendaten, Adresse, Planung und Entfernen. Nichts wird mehr abgeschnitten, und man sieht alles zu einer Seite auf einmal.
|
||
|
||
**Das Stylesheet ist geteilt.** `admin.css` war ein gewachsener Block, in dem Grundelemente, Verwaltungsseiten und die Editormaschine in denselben Zeilen standen. Die Editorregeln stehen jetzt in `editor.css`, das nur der Editor und die Mediathek laden; `admin.css` ist neu geschrieben und lesbar. Beim Zerlegen ist mir ein Fehler unterlaufen, den erst der Browsertest gefunden hat: mein Skript hat am Ende jedes `@media`-Blocks **alle** schließenden Klammern abgeschnitten statt nur einer. Die Datei war damit ab dem ersten Medienblock ungültig, der Browser hat 173 Regeln verworfen und nur 29 behalten – auffällig wurde das an der Vorschaubreite, die um vier Pixel abwich, weil der Standardrahmen des iframe nicht mehr entfernt wurde. Vier Pixel, hinter denen dreiviertel des Editor-Stylesheets fehlte.
|
||
|
||
**Systemschrift statt Arial**, und eine dunkle Seitenleiste als ruhiger Anker. Für die Verwaltung wird nichts nachgeladen; Google Fonts bleiben der Website vorbehalten, wo Umut sie auswählt.
|
||
|
||
## ADR 029 – Verwaltung: ein Rahmen statt sieben Kopfzeilen (12.09.2026)
|
||
|
||
Umuts Auftrag war „backend komplett neugestalten, einfacher schöner Bedienbarkeit komplett einfach“, mit ausdrücklicher Gestaltungsfreiheit. Das Problem war weniger die Optik als die Orientierung: jede Verwaltungsseite trug ihre eigene Kopfzeile und ihre eigene, unvollständige Liste von Links auf die anderen Seiten – die Seitenübersicht verlinkte sieben Bereiche, das Menü fünf, die Fassungen drei. Wo man war, stand nirgends.
|
||
|
||
**Ein Rahmen für alle.** `templates/admin/shell.html.twig` trägt die Navigation, die Meldungen und die Kopfzeile mit Titel, einem Satz Erklärung und Platz für die Hauptaktion. Jede Verwaltungsseite füllt nur noch `heading`, `lead`, `actions` und `content`. Die Navigation steht einmal da; die aktive Seite ist über `aria-current="page"` markiert, also nicht nur an der Farbe erkennbar. Auf schmalen Fenstern wird die Seitenleiste zu einer waagerecht scrollbaren Leiste – ohne JavaScript, weil die Verwaltung sonst keines braucht.
|
||
|
||
**Eine Zeile je Seite statt einer überladenen Karte.** Vorher hing an jeder Seite eine Reihe von sechs Aufklappfeldern, deren Inhalt als absolut positionierter Kasten über den Rand der Karte hinausragte. Jetzt zeigt die Zeile, was man täglich braucht – Titel, Adresse, Zustand, „Bearbeiten“, „Fassungen“ – und ein einziges Aufklappfeld „Einstellungen“ öffnet darunter, an seinem Platz, mit den Abschnitten Seitendaten, Adresse, Planung und Entfernen. Nichts wird mehr abgeschnitten, und man sieht alles zu einer Seite auf einmal. Tabellen scrollen seitlich, statt die Karte aus dem Fenster zu schieben.
|
||
|
||
**Das Stylesheet ist geteilt.** `admin.css` war ein gewachsener Block, in dem Grundelemente, Verwaltungsseiten und die Editormaschine in denselben Zeilen standen. Die Editorregeln stehen jetzt in `editor.css`, das nur Editor und Mediathek laden; `admin.css` ist neu geschrieben und lesbar. Der Editor behält seinen Aufbau – drei Spalten, Vorschau in der Mitte – und übernimmt die neue Gestaltung: Systemschrift, gleiche Radien, gleiche Schaltflächen, und ein paar Größen im Panel, die zum Lesen zu klein waren.
|
||
|
||
Beim Zerlegen ist mir ein Fehler unterlaufen, den erst der Browsertest gefunden hat: mein Skript schnitt am Ende jedes `@media`-Blocks **alle** schließenden Klammern ab statt nur einer. Die Datei war damit ab dem ersten Medienblock ungültig, der Browser behielt 29 von 202 Regeln. Symptom war eine Vorschaubreite von 1236 statt 1240 Pixeln, weil der Standardrahmen des iframe nicht mehr entfernt wurde – vier Pixel als einziges Anzeichen dafür, dass dreiviertel des Editor-Stylesheets fehlte.
|
||
|
||
**Systemschrift statt Arial**, und eine dunkle Seitenleiste als ruhiger Anker. Für die Verwaltung wird nichts nachgeladen; Google Fonts bleiben der Website vorbehalten, wo Umut sie auswählt.
|
||
|
||
## ADR 030 – Benutzerverwaltung: eine Rolle je Konto, und Sperren heißt Abmelden (12.09.2026)
|
||
|
||
Konten gab es bisher nur über die Konsole. Jetzt gibt es sie in der Verwaltung, mit drei Entscheidungen.
|
||
|
||
**Eine Rolle je Konto.** Gespeichert ist weiter eine Liste, weil die Sitzung sie so vergleicht, aber die Oberfläche kennt genau eine: Administrator, Veröffentlicher, Redakteur. Mehrere Rollen gleichzeitig hätte niemand erklären können, und die Rollenhierarchie in `security.yaml` leistet dasselbe.
|
||
|
||
**Wer gesperrt wird, wird abgemeldet.** Ein Passwort oder eine Rolle zu ändern beendet jede offene Sitzung dieses Kontos. Dafür war nichts zu bauen: `CmsUser::isEqualTo` vergleicht Passworthash und Rollen mit der Datenbank, und eine Sitzung, deren gespeicherter Stand abweicht, ist bei der nächsten Anfrage nicht mehr angemeldet. Genau eine Sitzung überlebt, nämlich die, die ihr eigenes Passwort geändert hat – der Controller legt ihr den frischen Benutzer in die Sitzung. Damit ist „jemanden sofort aussperren“ dasselbe wie „Passwort setzen“, und das steht auch so in der Oberfläche.
|
||
|
||
**Es bleibt immer ein Administrator übrig.** Nicht über eine Zählung, sondern über eine einfachere Regel: niemand kann sein eigenes Konto löschen oder herabstufen. Weil jede Änderung an einem Konto die Administratorrolle voraussetzt, braucht das Entfernen eines Administrators einen zweiten, der danach noch da ist. Ich hatte zuerst zusätzlich geprüft, ob noch ein anderer Administrator existiert – mit dieser Regel ist das unerreichbarer Code und wieder heraus.
|
||
|
||
Das eigene Passwort darf jeder ändern, aber nur mit dem aktuellen in der Hand: eine offen stehengelassene Sitzung soll nicht genügen, um ein Konto zu übernehmen. Untere Grenze 14 Zeichen, obere 72 Bytes, weil bcrypt alles darüber stillschweigend ignoriert. `cms:user:create` bleibt der Weg zurück, wenn kein Konto mehr funktioniert; das steht auch auf dem Bildschirm, samt Hinweis, dass es Dateizugriff auf den Server braucht.
|
||
|
||
## ADR 031 – Die Mediathek ist eine Seite, kein Knopf (12.09.2026)
|
||
|
||
Umut: „Ich finde mediathek komisch das sollte direkt laden in pagination“. Er hatte recht: der Bildschirm zeigte einen Knopf, der denselben Dialog öffnete, den der Editor zum Auswählen benutzt. Um zu sehen, was in der Mediathek liegt, musste man erst etwas anklicken.
|
||
|
||
Jetzt rendert `/admin/media` die Bilder selbst, 24 je Seite, neueste zuerst, mit Blättern über `?seite=` und einem Schalter für archivierte. Hochladen und Bildangaben laufen über gewöhnliche Formulare statt über `fetch`, also ohne JavaScript; dieser Bildschirm lädt weder `media.js` noch `editor.css`. Die Bild-ID steht in jeder Karte, weil die Felder für Beitragsbild und Teilen-Vorschau genau diese ID erwarten.
|
||
|
||
Hochgeladen wird über eine Ablagefläche, weil Umut das Auswahlfeld des Browsers zu Recht hässlich fand. Darunter liegt weiter ein echtes `input[type=file]`, nur unsichtbar und trotzdem fokussierbar; die Fläche ist dessen `label`, und der Fokusring wird über `:focus-within` auf die Fläche gezeichnet. Ohne JavaScript bleibt genau das übrig: ein Dateifeld mit Beschriftung. Das Skript ergänzt Ziehen, benennt die gewählten Dateien und überspringt alles, was kein JPEG, PNG oder WebP ist – abgeschickt wird nie von selbst, der Knopf bleibt die Stelle, an der etwas passiert. Ein `required` hat das Feld nicht: der Browser kann seine Meldung nicht an etwas Unsichtbares hängen, und der Server weist einen leeren Upload ohnehin mit einem lesbaren Satz ab.
|
||
|
||
Der Dialog bleibt, was er ist: der Weg, beim Bearbeiten ein Bild auszuwählen. Zwei Wege zum gleichen Bestand mit verschiedenen Aufgaben – `MediaLibrary::list()` bleibt für den Dialog neben `page()` bestehen, weil der Dialog seinen eigenen Filter mitbringt.
|
||
|
||
## ADR 032 – Einwilligung: das Banner ist der kleinere Teil (Dokumentschema v14, 12.09.2026)
|
||
|
||
Ein Einwilligungshinweis, der nur fragt und sonst nichts ändert, ist Theater: die Inhalte laden trotzdem, und die Frage war Dekoration. Deshalb besteht diese Umsetzung aus zwei Hälften, und die wichtigere ist die zweite.
|
||
|
||
**Die Frage.** Serverseitig gerendert, beantwortet durch ein Formular. Kein JavaScript – die öffentlichen Seiten laufen mit `script-src 'none'`, und ein Banner, das ein Skript braucht, wäre das eine, was diese Regel bricht. „Alle akzeptieren“ und „Nur notwendige“ sind dieselbe Art Knopf, nebeneinander, gleich groß; die Auswahl je Gruppe liegt in einem Aufklappfeld. Die Entscheidung steht in **einem** eigenen Cookie: Fassung der Frage, gewährte Gruppen, Zeitpunkt. Keine Kennung, kein Serverabgleich, ein halbes Jahr, dann wird erneut gefragt. Nicht signiert, und zwar absichtlich: das Cookie beschreibt, was dieser Browser gewählt hat, ein gefälschter Wert ändert also nur, was dieser Browser sieht.
|
||
|
||
**Das Zurückhalten.** Ein neuer Block `embed` (Dokumentschema v14) hält Inhalte anderer Anbieter. Ohne Einwilligung entsteht **keine Anfrage**: kein iframe, keine Adresse im Markup, kein `frame-src` in der Richtlinie. An seiner Stelle steht ein Platzhalter, der den Anbieter nennt und einen Knopf trägt, der genau diese Gruppe gewährt – und die vorher gewährten mitnimmt, statt sie zu überschreiben. Erst nach der Einwilligung wird der Host der Sicherheitsrichtlinie hinzugefügt, und auch dann nur der Host dieses einen Anbieters. Im Editor bleibt es immer ein Platzhalter: die Vorschau soll nichts von außen laden.
|
||
|
||
Der Server prüft die Adresse beim Speichern, nicht beim Ausliefern: nur HTTPS, ein einfacher Hostname, kein Port, keine Zugangsdaten. Der Grund ist, dass diese Adresse in eine Sicherheitsrichtlinie eingesetzt wird – ein Leerzeichen darin wäre eine zweite erlaubte Quelle.
|
||
|
||
**Kein CSRF-Token am Einwilligungsformular.** Die Anfrage ändert nichts auf dem Server; sie setzt ein Cookie, das nur den absendenden Browser betrifft. Ein Token bräuchte eine Sitzung, und für jeden Besucher eine Sitzung zu beginnen ist genau das, was ein Einwilligungshinweis vermeiden soll. Geprüft wird stattdessen das Rücksprungziel, damit aus dem Knopf keine Weiterleitung auf fremde Adressen wird.
|
||
|
||
**Ohne Grund keine Frage.** Ist der Hinweis abgeschaltet, gilt jede Gruppe als gewährt und die Inhalte laden. Sonst würde das Abschalten des Banners stillschweigend jede Seite leeren, die etwas einbettet.
|
||
|
||
## ADR 033 – Datenschutz und Impressum sind keine Einstellung (12.09.2026)
|
||
|
||
Umut: „datenschutz und Impressum einbauen sind keine option“. Richtig – in Deutschland sind beide Pflicht und müssen von jeder Seite aus unmittelbar erreichbar sein. Also:
|
||
|
||
- Fehlt eine der beiden, **warnt die Verwaltung** auf dem Einstellungsbildschirm, und ein Knopf legt die fehlenden Seiten an und verknüpft sie. Vorhandene Seiten werden nie überschrieben; sie tragen womöglich längst den echten Text.
|
||
- Die angelegten Seiten sind ein **Gerüst mit markierten Lücken**: jede Pflichtangabe steht als Abschnitt da, mit `NOCH AUSZUFÜLLEN` an der Stelle, wo eine Angabe über ein reales Unternehmen hingehört. Das CMS kann die Struktur vorbereiten, nicht die Erklärung schreiben – das ist eine rechtliche Aussage über eine Firma, und ein erfundener Text wäre schlimmer als eine leere Seite.
|
||
- Beide werden **von jeder Seite verlinkt**, aus dem Template und nicht aus dem Fußbereich. Der Fußbereich ist ein frei bearbeitbares Blockdokument; ein Pflichtlink, den man versehentlich löschen kann, ist kein Pflichtlink. Die Links liegen mit dem Link „Einwilligung ändern“ in einem benannten Landmark – loser Text außerhalb aller Landmarks ist auch das, was axe zu Recht beanstandet.
|
||
|
||
Eine Pfadangabe wird beim Speichern geprüft: zeigt sie auf eine Seite, die es nicht gibt, wird sie abgewiesen.
|