Umut hat den Nachbau Detail fuer Detail mit kant-it.de verglichen. Vier Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat. Der Slider zeigte eine Rollleiste statt Punkte. Punkte zeichnet jetzt der Browser selbst - scroll-marker-group und ::scroll-marker, ohne Skript, mit erkennbarem aktivem Punkt. Wo ein Browser das noch nicht kennt, bleibt das wischbare Schiebefeld. Pfeile gibt es bewusst nicht: Chrome legt ::scroll-button an den Seitenrand statt in den Slider. Und er rotiert nicht von selbst, weil eine oeffentliche Seite kein JavaScript laedt. Eine Beitragskarte war keine Karte, sondern eine lose Spalte. Jetzt weiss, 18px Ecken, Bild buendig oben im Verhaeltnis 16:10, Text darunter. Die Theme-Schriftskala ueberschrieb die Groessen der Bausteine: jede Kartenueberschrift war so gross wie eine Abschnittsueberschrift. Eine Ueberschrift darf ab v17 ein Wort hervorheben. Dabei waere beinahe eine Luecke entstanden: die Dokumentfassung war in der Schliessung des Filters nicht gebunden, der Filter lief nie, die Ausgabe war schon unescaped. Ein Test mit <script> in einer Ueberschrift hat es gezeigt. Geprueft: 390 PHP-Tests unter 8.2 und 8.4, 18 JS-Tests, Editortest, Browserpruefung, axe ohne Verstoss auf dem Nachbau. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 KiB
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, Symfony-Versionen. 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 (Qualität 82) bis 480/960/1600/2400 px Breite ohne Hochskalieren; ein schmaleres Original bekommt entsprechend weniger Stufen, die letzte davon in seiner eigenen Breite. 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.
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}.jsliefert die Skripte einer veröffentlichten Seite, jedes in einer eigenen Closure mit bereits gesuchtemelementund einemtry/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ältscript-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ÜLLENan 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.
ADR 034 – Statistik: Zähler statt Protokoll (12.09.2026)
Umut wollte wissen, „wieviel wann wo wie die Seite benutzt“ wird. Die naheliegende Lösung – ein Analysewerkzeug einbinden – hätte eine Einwilligung gebraucht, und damit hätte die Messung genau die Besucher verloren, die ablehnen. Also andersherum gebaut: eine Messung, die niemanden wiedererkennt und deshalb keine Einwilligung braucht.
Gespeichert wird ein Zähler, kein Datensatz je Aufruf. Eine Zeile je (Tag, Stunde, Pfad, Status, Verweis-Domain), die hochgezählt wird. Es gibt keine Spalte, aus der eine Person hervorgeht: keine IP-Adresse, keine Kennung, kein Cookie – auch nicht gehasht, denn der Hash einer IP-Adresse bleibt ein Personenbezug. Ein Test prüft die Spaltenliste der Tabelle gegen genau diese Zusage, damit sie beim nächsten Ausbau nicht unbemerkt verletzt wird.
Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam, kann einen Suchbegriff, einen Namen oder ein Token tragen – die wird nicht gespeichert.
Gezählt wird nach der Antwort, auf kernel.terminate: die Messung darf keine Anfrage verlangsamen, und ein Fehler beim Zählen darf keine Seite zerstören. Ein Ausfall kostet einen Zähler, nicht eine Seite. Gezählt werden nur GET-Anfragen auf HTML mit Status 200 oder 404; Verwaltung, Assets, Feed, Sitemap und die Einwilligungsseite sind ausgenommen.
Erfundene Adressen laufen gesammelt. Ein 404 behält seine Adresse nur, wenn sie aussieht wie eine Adresse dieser Website. Crawler probieren tausende Pfade wie /wp-admin/setup-config.php; jeder würde sonst eine eigene Zeile bekommen. Alles andere wird zu (sonstige).
Maschinen werden über die Kennung ausgeschlossen, und das ist eine Heuristik. Wer sich als Browser ausgibt, wird mitgezählt. Die Zahl heißt deshalb „Aufrufe, die sich nicht als Maschine gemeldet haben“ – so steht es auch auf dem Bildschirm, weil eine Zahl mit falschem Namen schlimmer ist als eine fehlende.
Die Diagramme zeichnet der Server als SVG mit Klassen statt Stilattributen. Kein Diagrammwerkzeug, kein Skript, und die Richtlinie bleibt unangetastet – die Verwaltung verbietet Inline-Stile, ein Test prüft, dass im SVG keines steht. Die Zahlen selbst stehen zusätzlich in einer Tabelle, denn ein Balken ist für einen Screenreader nichts.
Der Preis steht auf dem Bildschirm. Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist keine Lücke, sondern die Folge der Entscheidung: all das braucht Wiedererkennung. Wer es braucht, braucht eine Einwilligung – die Gruppe statistics dafür gibt es schon, sie wäre dann ein eigener Baustein.
Aufbewahrung: nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. cms:stats:prune macht beides und ist für einen Cronjob gedacht.
ADR 044 – Genauer hinsehen: Punkte statt Rollleiste, Karten statt Zeilen (13.09.2026)
Umut hat den Nachbau Detail für Detail mit dem Original verglichen, und er hatte in jedem Punkt recht. Was dabei herauskam, sind nicht Geschmacksfragen, sondern vier Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat.
Ein Slider ist kein Schiebefeld. Der Slider war ein Scrollbereich mit sichtbarer Rollleiste – im Hero sah das aus wie ein Versehen. Das Original hat Punkte. Punkte gibt es jetzt, und zwar ohne Skript: scroll-marker-group und ::scroll-marker lassen den Browser je Folie einen Punkt zeichnen, der aktive ist über :target-current erkennbar, und ein Klick scrollt. Wo ein Browser das noch nicht kennt (heute Firefox und Safari), bleibt ein wisch- und tastaturbedienbares Schiebefeld – also kein Ausfall, nur weniger Komfort. Die Rollleiste ist dort ausgeblendet, wo Punkte die Orientierung übernehmen.
Pfeile gibt es bewusst nicht. ::scroll-button() existiert, aber Chrome legt die Knöpfe weder absolut noch klebend in den Slider, sondern an den Rand der Seite. Lieber keine Pfeile als ein Knopf, der am Seitenrand schwebt.
Was der Slider nicht kann, steht hier: er rotiert nicht von selbst. Eine öffentliche Seite lädt kein JavaScript, und eine CSS-Animation, die sich nicht anhalten lässt, wäre ein Barrierefreiheitsproblem statt einer Lösung.
Eine Beitragskarte war keine Karte. Sie war eine Spalte aus Bild, Überschrift und Text ohne Rahmen. Im Original ist sie eine Karte: weiß, 18 px Ecken, Bild bündig oben im Verhältnis 16:10, Text in einem eigenen Feld darunter. Genau so sieht sie jetzt aus – und zwar für jede Website, weil es die Darstellung des Bausteins ist und keine Einstellung dieser einen Seite.
Die Schriftgrößen der Bausteine verloren gegen die Theme-Skala. .kant h3 aus dem Theme und .block-iconcard h3 aus site.css hatten dieselbe Spezifität, und der Themeblock steht später im Dokument – also gewann er. Jede Kartenüberschrift war so groß wie eine Abschnittsüberschrift, auf jeder Website mit eigener Schriftskala. Die Bausteinregeln tragen jetzt .kant davor und gewinnen wieder.
Eine Überschrift darf ein Wort hervorheben. Im Original ist die zweite Zeile der Hauptüberschrift rot. Dafür dürfen Überschriften ab v17 Auszeichnungen tragen – durch denselben Filter wie jeder Text. Dabei wäre beinahe eine Lücke entstanden: die Fassung des Dokuments war in der Schliessung, die den Filter läuft, gar nicht vorhanden; der Filter lief nie, und der Inhalt wird unescaped ausgegeben. Der Test, der ein <script> in eine Überschrift schreibt, hat es sofort gezeigt – und er bleibt genau dafür stehen. Zusätzlich entscheidet die Ausgabe jetzt an einer eigenen Fahne (richHeadings), damit eine ältere Seite ihre Überschrift weiterhin wörtlich zeigt.
ADR 043 – Was beim Nachbauen noch fehlte (Dokumentschema v17, 13.09.2026)
Umut hat den Nachbau angesehen und vier Dinge benannt: der Abstand zwischen den Lösungskarten und der Projektliste fehlt, die Karten sind zu hoch, die Beiträge sehen nicht aus wie im Original, und später: im Hero fehlen die letzten Projekte als Slider, der Kopfbereich klebt nicht beim Scrollen, das Logo verlinkt nicht auf die Startseite. Zwei davon waren mein Aufbau, vier waren fehlende Fähigkeiten.
Eine Beitragsliste kann jetzt ein Projektraster sein. Im Original zeigt eine Projektkarte Bild und Titel. Die Beitragsliste zeigte immer zusätzlich Datum, Autor, Auszug und Kategorie – richtig für ein Blogarchiv, falsch für eine Projektseite, und nicht abstellbar. Drei Schalter (showMeta, showExcerpt, showTerms) beantworten das, mit den bisherigen Werten als Voreinstellung, damit bestehende Seiten sich nicht ändern.
Dabei wäre es beinahe schiefgegangen, und zwar lautlos: {{ block.props.showMeta|default(true) }} gibt in Twig auch dann true zurück, wenn der Wert false ist – der default-Filter greift auf jeden leeren Wert, nicht nur auf einen fehlenden. Die Einstellung hätte sich speichern lassen und nichts getan. Mit ?? statt |default() bleibt false ein false. Der Test prüft beide Richtungen, weil nur die Gegenprobe diesen Fehler findet.
Ein Bild darf verlinken. Klingt selbstverständlich und fehlte: ein Logo im Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht. Die Adresse geht durch dieselbe Prüfung wie jeder andere Link, also kein javascript:, kein data:, kein schemaloses //fremd.test.
Der Kopfbereich kann beim Scrollen oben bleiben. Das ist bewusst eine Einstellung der Website und keine Eigenschaft eines Blocks: kleben kann nur das Element, das den Bereich umschließt (.site-header), und ein Abschnitt innerhalb davon würde nur solange kleben, wie sein Elternelement sichtbar ist – also gar nicht. Wer es im Designer an einem Abschnitt versucht hätte, hätte eine Einstellung gefunden, die nichts tut.
Der Slider im Hero brauchte nichts Neues: vier Folien mit Bild und Titel im vorhandenen Slider-Baustein. Ehrlich dazugesagt: er rotiert nicht von selbst. Öffentliche Seiten laufen ohne JavaScript, und daran ändert eine Animation nichts – gewischt, gescrollt und mit der Tastatur bedient wird er, und er rastet je Folie ein. Eine automatische Rotation wäre als reine CSS-Animation möglich, aber sie wäre nicht anhaltbar, und ein Karussell, das sich nicht anhalten lässt, ist ein Barrierefreiheitsproblem.
Und ein Fehler, der größer ist als alles andere in diesem Paket: die Liste bekannter Schemafassungen im Editor endete bei 12, während Bausteine längst v13 bis v15 verlangten. Folge: in einem aktuellen Dokument ließ sich im Editor gar kein Baustein mehr einfügen – jeder Versuch endete mit „Unbekanntes Dokumentschema“ – und Symbol, Fortschritt, Slider und eingebetteter Inhalt nirgends. Der Playwright-Editortest hat es nicht gefunden, weil er auf einem alten Dokument arbeitet und die betroffenen Typen nicht einfügt. Jetzt steht die höchste Fassung als eine Zahl an einer Stelle (SCHEMA_VERSION), und ein Test fügt jeden Bausteintyp in Dokumente von v1 bis v17 ein.
ADR 042 – Der Nachbau von kant-it.de, und was er gefunden hat (12.09.2026)
Umut: „Baue mal lokal kant-it.de komplett nach. Ich möchte sehen ob das funktioniert.“ Gebaut ist die Startseite mit allen neun Abschnitten, die Projekteseite, vier Projektbeiträge, Impressum und Datenschutzgerüst, Kopf- und Fußbereich, zwei Menüs, das Kontaktformular, zwölf Bilder und die Palette der echten Website – in einer eigenen Datenbank, damit die Entwicklungsinstallation unberührt bleibt.
Das Ergebnis der Übung sind nicht die Bilder, sondern fünf Fehler, die sonst ein Kunde gefunden hätte. Eine echte Website zu bauen ist eine andere Prüfung als ein Test: sie benutzt alles gleichzeitig.
1. Die Gestaltung färbte die Bausteine nicht. Symbole, Knöpfe, Rahmen, ruhiger Text und der Einwilligungsdialog kamen aus festen Token in site.css – eine Website mit roter Palette behielt das Grün der Vorlage. Die Farben ließen sich einstellen und taten nichts, dieselbe Familie wie Rahmenstärke und Bildrundung (ADR 036). Das Theme setzt jetzt --ink, --paper, --accent, --muted, --line und --mint selbst. Als Akzent dient die Linkfarbe, nicht ein Palettenname: dieses Token färbt auch kleine Beschriftungen, und eine helle Akzentfarbe wäre dort nicht mehr lesbar. --muted und --line werden aus Textton und Hintergrund gemischt; eine Palettenfarbe gleichen Namens gewinnt.
2. Der Einwilligungsdialog war grün, unabhängig von der Website. Er nimmt jetzt die Primärfarbe – und lässt sich eigens färben, indem im Design eine Farbe mit dem Schlüssel consent angelegt wird. Kein zusätzlicher Schalter, nur eine Regel, die eine Palettenfarbe bevorzugt.
3. „Di-gitale Exzellenz“. hyphens:auto galt für h1 bis h3. Im Fließtext ist Trennung nützlich, in einer 64-Pixel-Überschrift sieht sie nach einem Defekt aus – und genau dort fällt sie auf. Überschriften trennen nicht mehr.
4. Ruhiger Text auf dunklem Grund. .block-text hatte fest color:var(--muted), eine Farbe für helle Hintergründe. In einem dunkel gefärbten Abschnitt stand damit Grau auf Marineblau. Jetzt wird die Farbe aus der geerbten gemischt (currentColor zu 82 Prozent), womit dieselbe Regel auf hellem und dunklem Grund funktioniert. Dasselbe gilt für die automatische Kleinbeschriftung des ersten Textblocks: sie mischt den Akzent in die geerbte Farbe, statt ihn zu setzen.
5. Ein Kontrastfehler, den ich selbst eingebaut hatte. Die erste Ableitung von --muted mischte zu 72 Prozent und ergab für eine Palette mit hellem Textton 4,09:1 statt 4,5:1. axe hat ihn im selben Durchlauf gefunden, in dem die Farben zum ersten Mal wirkten. Jetzt 80 Prozent.
Was sich nicht als Fehler herausstellte, aber auffällt: Ecken und Seiten müssen einzeln angegeben werden (borderRadius als Kurzschreibweise wird abgewiesen) – der Designer schreibt das ohnehin so, von Hand ist es überraschend. Und die Beschriftung eines Einwilligungsfeldes ist auf 120 Zeichen begrenzt; der Einwilligungssatz der echten Website hat 148. Der Rest musste in den Hilfetext.
Was der Nachbau belegt: die Seite läuft mit null axe-Verstößen auf vier Seiten in Desktop- und Handybreite, ohne JavaScript, mit Bildern als WebP in vier Breiten und einer Startseite, die dem Original so nahe kommt, dass der Unterschied in der Typografie liegt und nicht in der Technik. Was fehlt, ist ehrlich zu benennen: die vollständige Datenschutzerklärung (das Gerüst ist da, der Text der laufenden Website ist länger), die übrigen Projektbeiträge (vier von fünf Seiten Archiv) und die Unterseiten von HausKlar und KantTresor.
ADR 041 – Der Installer: sechs Bildschirme, und danach ist er weg (12.09.2026)
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ und, später, „einen installer das eine php ist und alle dateien herunterladet“. Beides ist gebaut, und beides hängt an derselben Frage: was darf eine Datei dürfen, die auf einem leeren Webspace liegt und von jedem aufrufbar ist?
Der Ablauf schreibt jeden Schritt sofort, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es .env.local, nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
Das Ende ist das erste Konto, und es ist ein Zustand der Datenbank, keine Datei. Eine Datei, die den Installer sperrt, ist eine Datei, die jemand löschen kann – und dann steht auf einer laufenden Website wieder ein Formular, das ein Administratorkonto anlegt. Gibt es ein Konto, antwortet jede Adresse unter /install mit 404.
Kein Sitzungstoken, sondern eine Unterschrift. Das Sitzungscookie dieser Anwendung gilt nur für /admin – eine bewusste Entscheidung, die öffentliche Seiten cachebar hält –, und für sechs Bildschirme wird sie nicht aufgeweicht. Jedes Formular trägt Schritt und Zeitpunkt, mit APP_SECRET unterschrieben, zwei Stunden gültig. Ehrlich dazugesagt: das ist kein CSRF-Schutz, denn wer den Installer erreicht, bekommt auch eine gültige Unterschrift. Der Schutz ist, dass es den Installer nach dem ersten Konto nicht mehr gibt – deshalb steht auf dem letzten Bildschirm, dass sofort nach dem Hochladen installiert werden soll.
Migrationen laufen im Prozess, nicht über die Konsole. Auf geteiltem Hosting gibt es oft keine Shell, und proc_open ist ein häufiges Opfer einer gehärteten php.ini. Der Installer benutzt dieselbe migrations.php und dieselbe DependencyFactory wie das Konsolenkommando. Bricht der Browser ab, ist nichts kaputt: angewandte Migrationen sind vermerkt, ein erneuter Aufruf macht weiter.
Ein Huhn-Ei-Problem und seine Lösung: ohne APP_SECRET bootet Symfony nicht – also nicht einmal die Seite, die erklären würde, warum. Deshalb erzeugt der erste Start das Geheimnis selbst und schreibt es nach .env.local. Geht das nicht, läuft die Anfrage trotzdem, und die Systemprüfung sagt in einem Satz, dass im Projektstamm das Schreibrecht fehlt. Eine weiße Seite wäre die Alternative gewesen.
Und eine Weiterleitung statt eines Fehlers: solange keine Installation existiert, führt jede Adresse zu /install. Der erste Besuch auf einem frisch entpackten Webspace ist sonst ein 500 aus einer fehlenden Tabelle – genau der Moment, in dem jemand einen Satz braucht und keinen Stacktrace. Sobald ein Konto existiert, wird eine Marke unter var/installed geschrieben und keine Anfrage fragt die Datenbank noch einmal; die Marke schweigt nur diesen Subscriber, der Installer selbst fragt immer die Datenbank.
Die einzelne Datei davor (install.php) lädt das Releasearchiv, prüft dessen SHA-256, entpackt es und löscht sich danach selbst. Adresse und Prüfsumme stehen in der Datei, eingetragen beim Bauen des Releases: ein Installer, der eine URL aus einem Formular entgegennimmt, ist eine Einladung, fremden Code auf den Server zu legen. Stimmt die Prüfsumme nicht, wird nicht entpackt – es gibt kein Häkchen daran vorbei. Jeder Archiveintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird; geprüft wird nicht durch Suchen nach „..“, sondern indem der zusammengesetzte Zielpfad unterhalb des Ziels liegen muss. In ein Verzeichnis, in dem schon eine Installation liegt, arbeitet sie gar nicht erst.
Was der Installer anlegt, ist nicht die Demoseite. Eine Startseite mit dem Namen der Website, Kopf- und Fußbereich mit Menü – und Impressum und Datenschutzerklärung als Gerüst mit markierten Lücken. Das Letzte ist kein Entgegenkommen: eine deutsche Unternehmenswebsite ohne beides ist ab dem ersten Tag ein Problem, und die Installation ist der einzige Moment, in dem mit Sicherheit jemand hinsieht.
ADR 040 – Bildgrößen, und ein Release für Hosting ohne Composer (12.09.2026)
Zwei Fragen von Umut in einem Satz: „werden Bilder in verschiedenen Größen angelegt und in WebP umgewandelt?“ und „auf einem Hostingpaket ist kein Composer, meistens, oder?“ – die erste war zur Hälfte schon beantwortet, die zweite ist berechtigt und hatte bis heute keine Antwort.
Die Bilder: ja, und jetzt eine Stufe weiter. Ein Upload wird nie im Original gespeichert. GD decodiert ihn, dreht ihn nach EXIF gerade, behandelt Transparenz und schreibt WebP mit Qualität 82 – bisher in drei Breiten, jetzt in vier: 480, 960, 1600, 2400. Die vierte Stufe ist keine Kosmetik: ein vollflächiges Startseitenbild auf einem zweifach skalierten Bildschirm wurde vorher aus 1600 px hochgezogen und sah entsprechend aus. Hochskaliert wird dabei nichts – ist die Quelle 1800 px breit, ist die letzte Stufe 1800 px, nicht 2400.
Der Bildbaustein liefert srcset und sizes, der Browser wählt. Ein Hintergrundbild kann das nicht, denn CSS hat kein srcset – und genau da lag bisher die Lücke: ein Sektionshintergrund bekam immer die größte Variante, auch auf dem Telefon. Da die Gestaltung ohnehin je Gerät geschrieben wird, entscheidet nun das Gerät: Mobil 960, Tablet 1600, Desktop die größte vorhandene. Das ist die richtige Stelle dafür, weil die Regel ohnehin in einem @media-Block steht – es kostet kein Byte zusätzlich.
Der Preis, ehrlich benannt: vier Stufen kosten Rechenzeit und Platz beim Upload. Deshalb bleibt die konservative Prüfung gegen das Speicherlimit vor dem Decodieren stehen; sie ist es, die ein Hosting mit 128 MB vor einem Abbruch mitten im Schreiben bewahrt. Ein Test hat das sofort gezeigt, als er im Rahmen der gesamten Suite lief und der Speicher schon halb belegt war – das war kein Testproblem, sondern die Schutzfunktion bei der Arbeit.
Composer: Umut hat recht, und das ändert den Auslieferweg. Geteiltes Hosting hat in aller Regel keinen Composer, oft nicht einmal eine Shell. Die Frage stand seit docs/UPDATE.md offen („Composer auf dem Server oder vendor/ im Tag?“). Sie ist jetzt entschieden: es gibt ein Releasearchiv. scripts/build-release.php läuft auf einem Rechner, der Composer hat, baut vendor/ ohne Entwicklungspakete in eine Kopie des Projekts und schreibt var/releases/kantsites-<Fassung>.zip samt SHA-256. Im Archiv liegt die Anwendung und sonst nichts: keine Tests, keine Skripte, kein var/, kein .env. Das Git-Repository bleibt dabei frei von vendor/ – wer entwickelt, bekommt keine 3000 fremden Dateien in den Diff.
Daraus folgt eine Regel für den Updater, und sie ist wichtiger als das Archiv selbst: ein Update, das composer.lock ändert, bricht ab, wenn Composer auf dem Server nicht aufrufbar ist. Vorher hätte er den Code gewechselt und wäre mit einem vendor/ weitergelaufen, das nicht mehr zum Code passt – der Fehler wäre erst beim ersten Seitenaufruf aufgetaucht, nach dem Rauchtest, als weiße Seite. Jetzt vergleicht der Updater die Prüfsumme der Datei vor und nach dem Checkout und sagt in einem Satz, was zu tun ist: das Releasearchiv einspielen, das vendor/ mitbringt. Danach läuft der normale Rückweg.
Und ein Fehler, den erst der Browsertest zeigte: der Dokumentprüfer erlaubte drei sources je Bild. Die vierte Variante machte damit jedes Dokument mit einem Bild ungültig – ohne sichtbare Meldung, denn eine abgewiesene Speicherung sieht aus wie nichts. Das ist dieselbe Familie wie Rahmenstärke und Bildrundung: eine Zahl an einer Stelle, die niemand mit der Stelle verbindet, an der sie herkommt. Die Grenze liest jetzt MediaLibrary::WIDTHS, damit sie sich mit der Pipeline bewegt.
Nebenbei gefunden: CommandRunner konnte unter Windows composer nicht starten, weil das dort eine .bat ist und CreateProcess keine Stapeldatei ausführt. Ein Kandidat über %COMSPEC% /c behebt es – die Argumentliste bleibt fest, es kommt nichts aus einer Anfrage hinein.
ADR 039 – Messen statt vermuten: Abfragen, Ladebudget, Blättern (12.09.2026)
Arbeitspaket M sagte „Abfragen je Seite zählen. Messen, dann zusammenfassen.“ Messen braucht ein Messgerät, also gibt es jetzt eines.
Ein Zähler als Doctrine-Middleware zählt die Datenbankabfragen einer Anfrage, und außerhalb der Produktion steht die Zahl im Kopf X-Kant-Queries. Kein Profiler, kein Log – eine Zahl, damit „wie oft fragt diese Seite nach“ messbar statt geraten ist.
Die erste Messung: die Startseite brauchte 18 Abfragen. Nicht wegen einer komplizierten Seite, sondern weil SiteSettings::load() und Theme::load() je Aufrufer einmal dieselbe Zeile holten – der Renderer, die strukturierten Daten, der Einwilligungsschalter, die Vorlagen. Beide merken sich ihr Ergebnis jetzt für die Dauer einer Anfrage. Ergebnis: 18 → 8.
Dabei ist mir die klassische Falle des Merkens einmal selbst passiert, und der Test hat sie sofort gezeigt: ich habe den Merker am Anfang des Speicherns geleert. Das Speichern liest den Stand aber noch einmal (um die Vorlagen nicht zu verlieren), füllt den Merker damit erneut – und schreibt danach. Der Rest der Anfrage bekam den alten Stand. Geleert wird nach dem Schreiben.
Das Ladebudget wird gemessen, nicht behauptet. Die Browserprüfung liest je Seite HTML-Größe, CSS-Größe, Anzahl Anfragen, Anzahl Skripte und die Abfragezahl und prüft Obergrenzen. Heute: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 8 Abfragen, 0 Skripte. Die Null ist die wichtigste Zahl davon – sie hält die Zusage fest, dass eine öffentliche Seite ohne JavaScript vollständig ist, und zwar als Test statt als Absicht.
Das erste Bild einer Seite ist nicht mehr lazy. Es ist meistens das größte Ding über der Falz, und es zu verzögern verzögert genau das Pixel, auf das jemand wartet. Der Renderer bestimmt es in Renderreihenfolge – Kopfbereich zuerst, dann der Seiteninhalt – und gibt ihm loading="eager" und fetchpriority="high". Alles darunter bleibt lazy.
Eine feste Obergrenze ist kein Blättern. Der Fassungsverlauf hörte bei dreißig auf, die Einsendungen bei fünfzig, die Weiterleitungen zeigten alle bis zu zweitausend auf einmal. Das Erste sieht aus wie „es gibt dreißig“ und ist das Einzige, was eine Historie nicht tun darf. Alle drei blättern jetzt, mit derselben Rechnung (Pagination) und demselben Pager wie Beitragsarchiv und Mediathek.
ADR 038 – Sicherung: die Wiederherstellung ist die Funktion (12.09.2026)
Umut wollte eine Standard-Sicherungsfunktion. Gebaut ist sie so, dass der Teil im Mittelpunkt steht, den sonst niemand übt.
Die Datenbank wird nicht kopiert, sondern von SQLite ausgeschrieben. Ein copy() der Datei während eines Schreibvorgangs ergibt eine Datei, die vorhanden aussieht und sich am Tag der Wiederherstellung nicht öffnen lässt. VACUUM INTO liefert einen vollständigen, konsistenten Stand – ohne Wartungszustand, weil SQLite das selbst kann.
Der Medienordner gehört dazu, .env nicht. Ohne die Bilddateien stellt die Sicherung eine Website voller kaputter Bilder wieder her; die Dokumente verweisen nur auf IDs. .env dagegen enthält APP_SECRET, und eine Sicherung ist etwas, das heruntergeladen und weitergereicht wird. Sie wird zum Wiederherstellen von Inhalten auch nicht gebraucht. Im Manifest steht ausdrücklich, was nicht drin ist – damit niemand die Sicherung für vollständiger hält, als sie ist.
Jede Sicherung trägt ein Manifest mit Zeitpunkt, Anlass, Fassung, Migrationsstand, Inhaltszahlen und der SHA-256 der Datenbank. Die Übersicht zeigt „unversehrt“ oder „Prüfsumme falsch“, und eine Sicherung mit falscher Prüfsumme wird nicht eingespielt. Eine Sicherung, von der man erst beim Zurückspielen erfährt, dass sie kaputt ist, ist schlimmer als keine.
Beim Zurückspielen:
- Eine Sicherung aus einer neueren Fassung wird abgewiesen – ihr Schema kennt der laufende Code nicht. Mit
--forcegeht es trotzdem, weil es Fälle gibt, in denen genau das gewollt ist. - Zuerst wird der aktuelle Stand gesichert. Auch eine Wiederherstellung kann die falsche sein.
- Medien werden ergänzt, nicht ersetzt. Eine Datei, die nach der Sicherung hochgeladen wurde, bleibt liegen: die Maßnahme gegen Datenverlust darf nicht selbst welchen verursachen.
- Der Wartungszustand ist an, solange die Datenbank ersetzt wird.
Zurückspielen gibt es nur auf der Konsole. In der Verwaltung steht die Liste mit Zustand und ein Knopf zum Anlegen – ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert.
Ein kleiner Fall, der beim Testen auffiel: zwei Sicherungen in derselben Sekunde. Das ist nicht akademisch – eine Wiederherstellung legt eine Sicherheitskopie an, und die landet in derselben Sekunde wie die Sicherung, die gleich eingespielt wird. Der Name bekommt deshalb bei Bedarf eine Nummer, statt dass die zweite Sicherung fehlschlägt.
ADR 037 – Seitenbreite, Menüarten und Seiten ohne Kopfbereich (Dokumentschema v16, 12.09.2026)
Drei Fragen von Umut, drei unterschiedliche Antworten.
Die globale Seitenbreite war einstellbar und wirkte nur in eine Richtung. base.containerWidth gab es im Designbereich, aber site.css schrieb width:min(1180px,…) – jeder größere Wert wurde von der 1180 geschluckt. Schmaler ging, breiter nicht, und niemand hätte gesehen warum. Die Zahl kommt jetzt aus dem Theme (min(var(--k-container,1180px),…)). Das ist der dritte Fehler dieser Art an einem Tag: eine Einstellung, die sich speichern ließ und nichts tat. Alle drei hatten dieselbe Ursache – CSS, das nie gegen eine Erwartung geprüft wurde. Dagegen helfen die Datensatztests, die jetzt jede Deklaration nachrechnen.
Menüplätze und Menüarten sind zwei verschiedene Dinge, und das war vermischt. Wo ein Menü steht, bestimmt der Menüblock – er kann in jedem Bereich und jeder Sektion stehen, das war immer so. Welche Einträge es zeigt, bestimmt der Platz, und davon gab es nur zwei; jetzt sind es vier (Hauptnavigation, zweite Navigation, Fußzeile, Rechtliches). Wie es aussieht, war überhaupt nicht einstellbar; jetzt gibt es drei Arten: waagerecht, untereinander und aufklappbar als Burger. Der Burger ist ein details-Element – ein Menü, das ein Skript zum Öffnen braucht, wäre auf einer Seite ohne Skript ein Menü, das nicht aufgeht.
„Seiten ohne Menü“ war nicht möglich, und das ist der interessanteste Teil. Der Kopfbereich war ein einziges Dokument für alle Seiten. Jetzt wählt jede Seite: Standard, ein anderer Bereich, oder keiner. Dafür durfte die Regel „es gibt genau einen Kopf- und einen Fußbereich“ fallen – sie war richtig, solange niemand wählen konnte, und wurde falsch, sobald man wählen kann. Der erste Bereich behält die Adresse default und ist, was eine Seite ohne eigene Wahl bekommt.
Zwei Entscheidungen dabei:
- Gespeichert wird die Wahl, nicht das Ergebnis.
header_areaist leer für „Standard“,nonefür „keiner“, sonst die Adresse. Ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück, statt die Seite stillschweigend ohne Kopf auszuliefern – eine fehlende Navigation ist schlimmer als eine unerwartete. - Die Migration ist nicht umkehrbar. Ein
down()würde den Standardkopf auf Landingpages zurückbringen, die absichtlich keinen haben.
ADR 036 – Der Designer im Vergleich, und zwei Fehler, die er verdeckte (Dokumentschema v15, 12.09.2026)
Umut fragte nach Zeilen, nach Rahmen und Rundungen – und ob der Designer so aufgestellt ist wie Flatsome. Der Vergleich steht in docs/DESIGNER-VERGLEICH.md, samt der Einschränkung, dass ich Flatsome nicht selbst öffnen konnte. Was die Frage an Code auslöste:
Zwei Fehler, die vorher niemand gesehen hat. Eine Rahmenstärke je Seite wurde zu border-width-top – das ist kein CSS, der Browser verwirft die Zeile, und niemand bekommt eine Meldung. Rahmenstärken je Seite hatten damit nie eine Wirkung. Die Schreibweise steht jetzt als sideCss je Eigenschaft im Schema, weil padding-top dem Namen folgt und border-top-width nicht. Und eine Rundung auf einem Bildblock landete auf dem figure, während das Bild darin eckig blieb.
Eigenschaften können jetzt auf das Bild im Block zielen. Ein target: 'img' im Schema, und der Compiler schreibt eine zweite Regel #id img{…}. Das ist der Grund, warum object-fit überhaupt etwas tun kann: auf einem figure tut es nichts. Damit kommen Bildausschnitt, Bildmitte, Seitenverhältnis, Bildhöhe, Bildecken und Bildrahmen.
Von 50 auf 93 Eigenschaften. Dazu Positionierung mit z-index (freigestellte Elemente, wie in Flatsome-Bannern), getrennte Zeilen- und Spaltenabstände, Flex-Eigenschaften je Element, Innenschatten, Textschatten, acht Bildfilter als eine zusammengesetzte filter-Deklaration in fester Reihenfolge, Mischmodus und Mauszeiger. Neun Einblendungen mit Verzögerung, sieben Hover-Effekte – alle innerhalb von prefers-reduced-motion.
Drei neue Bausteine (v15). Symbol als eigenes Element; Fortschritt mit role="meter" und dem Wert im Markup statt nur in der Balkenbreite; und ein Slider ohne JavaScript – ein Scrollbereich mit Einrasten, sichtbare Folien je Gerät, ohne Autoplay. Kein Autoplay ist eine Entscheidung: ein sich selbst bewegendes Karussell ist etwas, das Menschen anhalten wollen.
Weil Inline-Stile verboten sind, sind Symbolgröße und Fortschrittswert Klassen. Beim Symbol heißt das: nur die Größen aus einer festen Liste sind erlaubt – ein freier Wert hätte keine Klasse, und ein SVG ohne Maße ist unsichtbar. Der Validator kennt die Liste, der Editor bietet genau sie an.
Zeilen waren nicht unauffindbar, sondern unbrauchbar leer. „Zeile“ stand immer in der Elementliste, aber eine Zeile ohne Spalten sieht aus wie nichts. Jetzt gibt es „Zeile, 2/3/4 Spalten“ auf einen Klick und im Werkzeugkasten den Satz, wie der Aufbau gemeint ist: Sektion → Zeile → Spalte → Inhalt.
ADR 035 – Der Updater: jeder Schritt prüft, der Rückweg ist Teil des Wegs (12.09.2026)
Umgesetzt wie in docs/UPDATE.md geplant, mit drei Dingen, die in der Umsetzung wichtiger wurden als im Plan.
Die Vorprüfung ist die halbe Arbeit. Sie fragt nach proc_open (auf geteiltem Hosting oft abgeschaltet), nach Git, nach einer sauberen Arbeitskopie, nach der Herkunft, nach Schreibrechten, nach Platz – und danach, ob Datenbank und Medienordner wirklich von Git ausgenommen sind. Das Letzte ist der Punkt, an dem ein Update Inhalte zerstören könnte, und es wird geprüft, nicht angenommen. Jede Prüfung liefert einen Satz, der sagt, was zu tun ist, nicht bloß „fehlgeschlagen“.
Abgebrochen wird auf den vorherigen Stand zurück, und dann geht die Website wieder an. Ob der Wartungszustand bestehen bleibt, entscheidet der Rauchtest des alten Stands: antwortet er, wird der Zustand aufgehoben – die Website läuft dann auf der alten Fassung, was der Sinn eines Rückwegs ist. Antwortet er nicht, bleibt sie im Wartungszustand, weil eine kaputte Seite schlechter ist als eine ehrliche Meldung. Die Datenbank wird nur zurückgeschrieben, wenn die Migration schon begonnen hatte: sonst würde das Zurückschreiben Inhalte verlieren, die seit dem Start des Updates entstanden sind.
Der Rauchtest läuft als eigener Prozess. cms:health prüft Datenbank, Migrationstabelle und ob die Startseite veröffentlicht lesbar ist – in einem frischen PHP-Prozess, denn nur der führt den neuen Code aus. Ein Test im laufenden Prozess hätte den alten geprüft und „alles in Ordnung“ gemeldet.
Dazu drei kleinere Entscheidungen:
- Kein
symfony/process. Der Runner sind dreißig Zeilen überproc_openmit einem Befehl als Array – kein Shell, nichts zu maskieren. Das vermeidet eine neue Abhängigkeit und macht den Fall „proc_open ist abgeschaltet“ zur ersten Antwort statt zur Überraschung. var/maintenanceals Schalter, ausgewertet vor dem Router. Öffentliche Anfragen erhalten 503 mitRetry-After, damit eine Suchmaschine „später wieder“ liest statt „hier steht jetzt eine Entschuldigung“./adminbleibt erreichbar, sonst könnte niemand das Update beenden oder zurücknehmen.- Der Bildschirm sagt, dass die Konsole verlässlicher ist. Ein Update im Browser muss innerhalb des Zeitlimits des Hostings fertig werden, und das kann diese Anwendung nicht versprechen.
Geprüft wird der Updater gegen echte Git-Repositorys in einem Temp-Verzeichnis, nicht gegen dieses Projekt: ein Test, der die Arbeitskopie auscheckt, in der er läuft, ist ein Weg, Arbeit zu verlieren. Die zwei Befehle, die der Updater aufruft, sind dort Platzhalterskripte – so prüft der Test die Reihenfolge und den Rückweg mit echtem Git, echten Dateikopien und echtem Scheitern, ohne zwei Minuten Composer.