174 KiB
Architekturentscheidungen
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
ADR 080 – Prozentuale Bildbreite direkt bei den Bildoptionen (14.09.2026)
Der Inhaltsbereich eines Bildes erhält oben einen Prozentregler und ein Zahlenfeld für 1–100 % der verfügbaren Containerbreite. Beide schreiben ausschließlich style.<device>.width über die bestehende setStyle-Transaktion. Die generische Breite unter Design und die direkte Bildsteuerung sind dadurch immer derselbe Wert. Originalmaße, Quelle, Varianten, Alternativtext und Bildunterschrift bleiben unverändert. Vorhandene maximale Breiten werden erhalten und im Hinweis genannt; Werte in anderen Einheiten bleiben sichtbar, bis bewusst eine Prozentbreite gesetzt wird. Keine neue Style-Eigenschaft, keine neue Schema- oder Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen JavaScript.
Die Steuerung verwendet das aktive Vorschaugerät und zeigt die Herkunft des Werts. Zurücksetzen entfernt nur die Breite dieses Geräts und stellt die bestehende Desktop→Tablet→Mobil-Vererbung wieder her. Eingaben aktualisieren die Vorschau, ohne das Feld zu ersetzen. Eine Ziehbewegung ist ein Undo-Schritt; Zahlenwerte außerhalb des Bereichs werden mit verständlicher Meldung abgelehnt und nicht gespeichert. Tastatur, Undo/Redo, Speichern/Laden und prozentuale Ausgabe ohne JavaScript werden am realen TeamViewer-Bild aus einer isolierten Kopie der Pilotdaten geprüft.
ADR 079 – Livesuche mit gemeinsamen HTML-Ergebnissen (14.09.2026)
Seiten, Beiträge und Mediathek verwenden dieselben authentifizierten GET-Routen für vollständige Ansichten und AJAX. Der Header X-Requested-With: XMLHttpRequest fordert JSON mit einem serverseitig gerenderten Ergebnisfragment, Gesamtzahl und normalisierten Filtern an. Twig bleibt die einzige Darstellung für Listen, Berechtigungen, CSRF-Felder, leere Ergebnisse und Pagination. Suchformular sowie Anlegen/Hochladen bleiben außerhalb des austauschbaren Bereichs; vorhandene Formulare funktionieren weiter ohne JavaScript. Keine Schema- oder Inhaltsmigration.
Das Verwaltungsmodul wartet beim Tippen 220 ms und ersetzt nur die Ergebnisliste. Eine neue Eingabe bricht die alte Anfrage sofort ab; eine zusätzliche Sequenzprüfung verwirft verspätete Antworten. IME-Eingaben werden erst nach der Komposition gesucht. Beginnt während einer ausstehenden Suche eine Bearbeitung in der Liste, wird die Suche angehalten, damit die Antwort keine Eingabe entfernt. Nach dem Austausch werden Seitenauswahlen initialisiert und die Rückkehrfilter aller Formulare aktualisiert. Fehler erhalten die bisherigen Ergebnisse und bieten Wiederholen an. Ergebniszahl und Ladezustand sind für Screenreader zugänglich; Tastaturfokus bleibt beim Tippen erhalten.
URLs enthalten den Suchzustand. Tippen ersetzt den aktuellen Historieneintrag, Filterwahl und Blättern legen einen neuen an; popstate lädt passende Ergebnisse und stellt die Felder wieder her. Ohne JavaScript arbeitet das GET-Formular mit Submit und regulären Links. Das Modul gehört ausschließlich zum Admin-Paket.
Die Mediathek filtert Titel und Alternativtext vor COUNT und LIMIT in der Datenbank, inklusive Archivfilter und wörtlicher Behandlung von %, _ und !. SQLite erhält eine Unicode-fähige LOWER-Funktion über PDO/mb_strtolower, weil die eingebaute Funktion nur ASCII verarbeitet und etwa „über“ nicht zu „Über“ passte. MySQL verwendet weiterhin seine native Funktion. Die Korrektur gilt auch für Inhalts- und Menüsuche; bestehende Daten und Indizes werden nicht verändert.
ADR 078 – Kontrollierbare Slider und Einblendungen beim Erscheinen (14.09.2026)
Die bisherige Scroll-Zeitachse ersetzte unterschiedliche Effekte durch dieselbe Aufwärtsbewegung. motion.js beobachtet stattdessen sichtbare Blöcke und spielt die gewählte Richtung/Überblendung/Skalierung/Unschärfe einmal mit der Web Animations API. Verzögerungen bleiben erhalten. Ohne JavaScript sind alle Inhalte sichtbar; reduzierte Bewegung unterbindet die Wiedergabe und bricht laufende Animationen ab. Nur Dokumente mit einem Effekt laden das Modul, einschließlich Kopf/Fuß und verknüpften Sektionen. Inhalt und Dokumentschema bleiben unverändert.
Slider behalten Scroll-Snap als Grundlage. Ein kleines Modul ergänzt sichtbare Pfeile, Zähler, optionale Punkte sowie Pause/Wiedergabe bei Autoplay. Manuelle Bedienung stoppt auch einen bereits geplanten Timer. Automatik wartet bei Hover, außerhalb des Bildschirms oder in unsichtbaren Tabs; Bewegungsreduktion deaktiviert sie. Ohne Skript bleibt der Inhalt scrollbar. Im Editor startet der Benutzer diese Module ausdrücklich aus dem Elternfenster; die iframe-Sandbox bleibt allow-same-origin ohne allow-scripts. Neue Vorschauen räumen Observer, Timer und Ereignisse der alten Instanz auf.
ADR 077 – Begrenzte Inhaltslisten und Menüaufbau mit Suche (14.09.2026)
Seiten und Beiträge erhalten getrennte Routen und Navigation. Die Datenbank führt Typ, Suche, Status, COUNT und LIMIT/OFFSET aus; nur 20 oder 50 Dokumente werden pro Ansicht dekodiert. Stabile Sortierung verhindert Überschneidungen bei identischen Zeitstempeln. Papierkorb und leere Suchergebnisse folgen demselben Vertrag. Keine Schemaänderung. Unterlagen mit Titeln und Pfaden werden höchstens in Gruppen von 20 als authentifizierte Suchvorschläge geliefert. Eine separate ID-Abfrage hält bereits ausgewählte Eltern unabhängig von der Ergebnisseite sichtbar.
Der Menüeditor bearbeitet einen Menüplatz je Ansicht. Suche und eigene Links stehen links, Reihenfolge und Felder rechts. Hinzufügen, Haupt-/Unterpunkte, Umordnen und Entfernen sind per Buttons/Tastatur bedienbar und rückgängig zu machen. Haupteinträge ziehen ihre Kinder mit; beim Entfernen bleiben deren Links als Hauptpunkte erhalten. Speichern verwendet weiterhin die validierte Menüstruktur und erwartete Version, liefert bei JavaScript JSON und behält bei 422/409 oder Verbindungsfehlern die Eingaben. Keine neuen Datenformate.
Der Fehler 500 bei Bereichen entstand durch einen unbedingten Zugriff auf page.slug in der gemeinsamen Editorvorlage. Bereiche haben keine Seite. Routen und öffentliche Links richten sich jetzt nach dem bearbeiteten Objekt; Speichern eines Bereichs ist sichtbar als sofort wirksame Änderung bezeichnet. Der API-Pfad wird aus benannten Routen abgeleitet, auch nach Verlegung der Verwaltung.
ADR 076 – Eine Installationsdatei und sichtbare Datenbankquelle (14.09.2026)
.env ist die einzige automatisch geladene Konfigurationsdatei. Sie gehört zur Installation und wird nicht mehr von Git versioniert. Installer, Setup und Admin-Pfad-Befehl schreiben atomar und unter einer Dateisperre in diese Datei, ohne bestehende fremde Schlüssel zu verwerfen. Ein Marker unterscheidet eine vom Installer bestätigte Datenbank von der bloßen Vorlage. Echte Prozess-/Servervariablen bleiben bewusst höher priorisiert.
Beim ersten Boot mit einer alten .env.local bleiben deren bisher wirksame Werte erhalten: beide Originale sichern, zusammengeführte .env schreiben, erst danach .env.local entfernen. Sicherungen liegen außerhalb des Webroots unter var/backups/environment-*. Secret und Datenbank werden nicht neu erzeugt oder gewechselt. Ein Schreibfehler bricht die Migration ab, statt still auf Standarddaten zu wechseln. Andere Symfony-Overlays werden nicht mehr implizit geladen; eine manuelle Nutzung benötigt vor dem Wechsel die Übernahme in .env/Servervariablen. Rückweg: Code und beide Konfigurationssicherungen gemeinsam wiederherstellen.
Der Git-Updater sichert Konfigurationsinhalte vor dem Checkout und stellt sie vor jedem neuen PHP-Prozess wieder her, auch beim Rollback. Das behandelt ausdrücklich ältere Repositorys mit getrackter .env. Der Archiv-Updater nimmt diese Dateien weiterhin vom Dateiaustausch aus. Zusätzlich wurde ein vorhandener Rückwegfehler behoben: Der Git-Updater speicherte nur einen Beschreibungstext und leitete daraus einen ungültigen Datenbank-Sicherungspfad ab. Der tatsächliche Sicherungsordner wird jetzt getrennt geführt. Ein fehlgeschlagenes Zurückschreiben lässt den Wartungszustand aktiv; der Regressionstest verändert die Datenbank vor dem simulierten Migrationsfehler und prüft ihre Wiederherstellung. Bereits installierter alter Git-Updater-Code kann diesen neuen Schutz beim erstmaligen Wechsel noch nicht ausführen; daher diesen Übergang per Archiv-Update durchführen oder die Konfiguration vor dem manuellen Checkout sichern und anschließend wiederherstellen (UPDATE.md).
Die bereits begonnene Systemdiagnose bleibt erhalten: tatsächliche Datenbankart, SQLite-Pfad/Größe/Schreibrecht beziehungsweise maskierte Serveradresse sowie die tatsächliche Quelle. Die Quelle stammt aus den vom Dotenv-Lader gesetzten Variablen; sie wird nicht aus der bloßen Existenz einer Datei geraten. Passwort und Secret werden nicht angezeigt.
ADR 075 – Leere Container sind Arbeitsflächen (14.09.2026)
Leere Spalten hatten keine Höhe; leere Sektionen waren zwar durch Abstände vorhanden, aber weder als Container erkennbar noch mit einem direkten Einfügeweg versehen. Der lokale Flatsome-3.20.9-Quelltext verwendet für solche Zustände eine eigene sichtbare Inhaltsfläche mit Mindesthöhe und mittigem Einfügewerkzeug (konkrete Fundstellen in docs/FLATSOME-ABGLEICH.md).
preview-containers.js ergänzt deshalb erst im vollständig geparsten Vorschau-DOM sichtbare Hilfsflächen für leere Sektionen, Zeilen und Spalten. Eine leere Sektion bietet außerdem Zeilenlayouts mit 1–4 Spalten. Die Hilfen sind echte Buttons mit Tastaturbedienung und eindeutigen Namen, etwa „Spalte 2 von 2: Inhalt hinzufügen“. Sie setzen einen genauen Einfügepunkt oder führen dieselbe Modelltransaktion wie der Werkzeugkasten aus. Abbrechen oder eine andere Auswahl hebt den Einfügepunkt auf.
Die Editor-CSS gibt den Flächen mindestens 130 Layoutpixel Höhe. Der öffentliche Renderer und seine CSS bleiben unverändert; die Mindesthöhe ist eine Bearbeitungshilfe und wird nicht als Designwert gespeichert. Nach dem ersten Inhalt verschwindet die Hilfe automatisch, Undo stellt sie wieder her. Kein Schemawechsel und keine Datenmigration; bestehende leere Container erhalten die Hilfe allein beim Öffnen. preview.css und das neue Modul erhalten die Versionskennung 1.6.2, damit ein bestehender Browsercache die Funktion nicht verdeckt.
Neu eingefügte Elemente werden nach dem Rendern sichtbar gescrollt. Die Vorschau gilt erst mit geparstem DOM und geladenen Hilfsstilen als bereit. Die Drop-Geometrie wird beim Scrollen und beim Loslassen neu ermittelt, Zielcontainer werden benannt, und nebeneinanderstehende Spalten werden horizontal eingeordnet; mobil gestapelte Spalten vertikal. Ein Ziehen nahe dem Vorschaurand scrollt den Frame, bis der Zeiger den Rand verlässt oder das Ziehen endet. Inhalt und Undo bleiben von bloßem Scrollen unberührt.
Der Offen-/Geschlossen-Zustand von Feinschliff-Gruppen wird direkt aus ihren bisherigen DOM-Knoten übernommen. Ein verzögertes toggle-Ereignis allein reicht nicht: Ein schneller Neuaufbau nach einer Wertänderung kann den Knoten entfernen, bevor das Ereignis ankommt, und würde die gerade geöffnete Gruppe wieder schließen.
ADR 074 – Editorfläche, Seitenebene und kontextbezogene Gestaltung (14.09.2026)
Textblöcke sowie H1/H2 hatten im Basis-CSS feste Höchstbreiten von 690/900/760 px. Sie folgen jetzt der Breite ihres Containers; ausdrücklich gespeicherte maxWidth-Werte bleiben wirksam. Sektionsbreiten und Theme-Container bleiben bewusste Layoutentscheidungen. Das verändert gegebenenfalls den Zeilenumbruch bestehender Seiten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Quelle. Kein Dokument wird dafür umgeschrieben, keine Schema- oder Datenbankmigration ist erforderlich. Rückweg: vorheriges Basis-CSS oder eine explizite maximale Breite am betreffenden Block.
Ein Klick auf „Sektion“ erzeugt eine neue Sektion nach dem obersten Block der aktuellen Auswahl. Die dauerhaft sichtbare Aktion „Neue Sektion auf Seitenebene“ verwirft zuvor gesetzte Einfügepunkte. Bewusst gesetzte Plus-Einfügepunkte und Drag-and-drop dürfen weiterhin verschachteln. Die Einfügeauskunft und die Modellaktion verwenden denselben Zielermittler; Änderungen bleiben einzelne Undo-Schritte.
Die Seitenleisten sind unabhängig zwischen 240/280 und 560 px verstellbar, soweit wenigstens 280 px für die Vorschau bleiben. Ziehen, Pfeiltasten, Home/End und Doppelklick sind unterstützt. Präferenzen liegen lokal im Browser, außerhalb von Inhalt, Revisionen und Undo. Schmalere Fenster reduzieren die sichtbaren Breiten vorübergehend; bis 850 px folgen die Panels der Vorschau, bis 560 px stehen sie untereinander. Die Vorschau hat keinen zusätzlichen Außenabstand; Desktop wächst oberhalb von 1240 px mit dem verfügbaren Platz, Tablet/Mobil behalten ihre definierten Layoutbreiten.
Der Feinschliff filtert nach Blocktyp und ordnet passende Gruppen zuerst: Bildausschnitt bei Bildern, Typografie bei Text, Layout bei Containern. „Alle Eigenschaften anzeigen“ öffnet den vollständigen Vertrag. Bereits gespeicherte Eigenschaften bleiben auch außerhalb des üblichen Blockprofils auffindbar; Validierung und gespeichertes Schema ändern sich nicht. Beim Auswahlwechsel beginnt das Panel oben. auto beziehungsweise none sind für die passenden Größenfelder explizit auswählbar. VERSION 1.6.1 erneuert die Cachekennung aller Editor-Module.
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. Die Aufgabe publish-due veröffentlicht, was fällig ist, und löscht den Zeitpunkt (ADR 073); ohne einen Auslöser – Besuch, geöffnetes Backend, URL-Cron oder Konsole – 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; im Normalbetrieb erledigt das die Aufgabe stats-prune des Plans (ADR 073).
ADR 073 – Wiederkehrende Aufgaben ohne Cronjob: die Anfrage ist der Auslöser (14.09.2026)
Bis hierher stand in vier Dokumenten derselbe Satz: „gehört in einen Cronjob". Das ist richtig – und auf dem Hosting, für das dieses CMS gebaut ist, oft nicht einlösbar. Kein SSH, kein Supervisor, manchmal nicht einmal ein Cron-Eintrag im Kundenmenü. Die Folge war nicht „läuft seltener", sondern läuft nie: keine Sicherung, keine geplante Veröffentlichung, eine Statistiktabelle, die nur wächst.
Die Entscheidung: Was ein Hosting immer hat, sind Anfragen. Also arbeitet eine Anfrage die Aufgaben ab – in kernel.terminate, nach der ausgelieferten Antwort. Unter PHP-FPM ruft Symfony dort fastcgi_finish_request() auf: der Browser ist fertig, während hier noch gearbeitet wird. Das ist derselbe Weg, den WordPress (wp-cron) und Shopware gehen, und aus demselben Grund.
Vier Auslöser, eine Mechanik:
| Auslöser | Wer stößt an | Grenzen je Durchlauf |
|---|---|---|
| Besuch | jeder Seitenaufruf | 2 Aufgaben, 20 s |
| Verwaltung | das geöffnete Backend, im Minutentakt | 3 Aufgaben, 45 s |
| URL-Cron | der Hoster ruft /cms-cron/<Schlüssel> |
20 Aufgaben, 240 s |
| Konsole | cms:tasks:run, auch mit --loop |
frei |
Was das nicht kann, steht auf dem Bildschirm und nicht im Kleingedruckten: Ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr. Für Sitemap, Aufräumen, Updateprüfung und Sicherung ist das brauchbar; für etwas Minutengenaues ist es das nicht, und dann ist einer der beiden unteren Wege die Antwort. Genau dieser Satz steht im Bildschirm „Aufgaben", weil die Alternative ist, dass jemand später an der falschen Stelle sucht.
Drei Eigenschaften entscheiden darüber, ob das im Betrieb trägt:
1. Der Normalfall kostet keine Datenbankabfrage. In var/scheduler.json steht, wann überhaupt das nächste Mal etwas ansteht, dazu die zwei Schalter. Liegt der Zeitpunkt in der Zukunft, ist der Abonnent nach einem file_get_contents fertig. Fehlt die Datei – frische Installation, geleertes var/, kein Schreibrecht –, wird die Datenbank gefragt und die Datei neu geschrieben: der teure Weg ist der Ausnahmefall, nicht der Normalfall, und ein schreibgeschütztes var/ macht das System langsamer, aber nicht falsch.
2. Zwei Besucher führen eine Aufgabe nicht zweimal aus. Wer sie ausführen darf, entscheidet ein bedingtes UPDATE … WHERE next_run_at <= :jetzt AND (locked_until IS NULL OR locked_until <= :jetzt). Das ist auf MySQL wie auf SQLite atomar; wer keine Zeile verändert hat, war zu spät und geht weiter. Deshalb ist der Plan eine Tabelle und keine Datei: eine Dateisperre ist auf geteiltem Hosting nicht zuverlässig, ein bedingtes UPDATE ist es. Die Sperre verfällt nach 15 Minuten von selbst, denn ein Prozess, den das Hosting mitten im Lauf abschießt, kann sie nicht mehr aufräumen.
3. Eine Aufgabe darf niemals die Website mitreißen. Der Abonnent fängt alles. Ein Fehlschlag wird zum sichtbaren Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden) – sonst fängt eine Aufgabe, die nicht laufen kann, bei jedem Seitenaufruf neu an. Fehlt die Tabelle, weil Dateien hochgeladen und Migrationen noch nicht ausgeführt wurden, passiert schlicht nichts.
Die Sicherung läuft mit, obwohl sie die schwerste Aufgabe ist. Sie kopiert Datenbank und Medien, und unter Apache mit mod_php belegt sie dabei einen Prozess, weil es dort kein fastcgi_finish_request() gibt – die Seite steht längst im Browser, der Prozess bleibt. Der Handel ist trotzdem eindeutig: eine tägliche Sicherung auf einem Hosting ohne Cron ist mehr wert als gar keine. Wer einen echten Cronjob hat, schaltet den Besuchs-Auslöser ab; der Bildschirm sagt auch das.
Der Schlüssel des URL-Cronjobs steht in keiner Datenbank. Er wird aus APP_SECRET abgeleitet (hash_hmac, 40 Zeichen), ist damit auf jeder Installation ein anderer, überlebt jedes Update und wechselt genau dann, wenn das Geheimnis wechselt. Ein falscher Schlüssel bekommt 404 und nicht 403: wer probiert, soll nicht erfahren, dass es die Stelle gibt.
Eine neue Aufgabe ist eine Datei. Wer App\Ops\Tasks\Task implementiert, bekommt über _instanceof ein Etikett, liegt beim nächsten Abgleich in der Tabelle und steht im Bildschirm – ohne eine Liste, die an drei Stellen nachzuführen wäre. Verschwindet die Klasse, verschwindet die Zeile. Mitgeliefert sind fünf: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich).
ADR 072 – Drei Fehler einer Familie: dieses Hosting ist nicht mein Rechner (13.09.2026)
Umut, während er 1.4.4 in Betrieb nahm: „Das buggt doch immer noch" und „Bei Sicherung anlegen kommt auch Fehler 500 – wieso ist das alles verbuggt?" Drei Befunde an einem Abend, und sie gehören zusammen: Jeder einzelne war auf meinem Rechner unsichtbar.
1. git installiert ist nicht dasselbe wie „Arbeitskopie". Updater::state() gab unter dem Schlüssel git zurück, ob der Befehl git aufrufbar ist. Auf Umuts Hosting ist er das (Version 1.8.3.1), nur ist das Verzeichnis eine Installation aus dem Releasearchiv. Also lief jeder Git-Aufruf ins Leere, und in der Verwaltung stand dort, wo eine Fassungsnummer hingehört: fatal: Not a git repository. Dazu eine Vorprüfung voller roter Ausrufezeichen für Bedingungen, die für diese Installation gar nicht gelten. Die Software war in Ordnung und sah kaputt aus – für den Betreiber ist das dasselbe. Der Zustand heißt jetzt worktree und wird getrennt von git erhoben; ohne Arbeitskopie verschwinden die Git-Karten vollständig, und an ihrer Stelle steht ein Satz: „Eingerichtet über das Releasearchiv."
2. VACUUM INTO gibt es erst seit SQLite 3.27. Die Sicherung benutzt es, weil eine Dateikopie während eines Schreibvorgangs unbrauchbar sein kann (ADR 038). Auf Umuts Hosting läuft eine ältere Fassung: alt genug für die Installation (die braucht 3.16, ADR aus 1.4.2), zu alt für die Sicherung. Ergebnis: 500 beim Knopf „Sicherung jetzt anlegen". Jetzt wird bei älteren Fassungen unter einer Schreibsperre kopiert – PRAGMA wal_checkpoint, BEGIN IMMEDIATE, kopieren, COMMIT –, und der Test setzt die Schwelle künstlich hoch, damit dieser Weg wirklich läuft und nicht nur auf fremden Servern.
3. Ein unerwarteter Fehler darf keine 500-Seite sein. Der Sicherungsknopf fing InvalidPage ab, also die erwarteten Fälle. Alles andere – alte Datenbankfassung, fehlendes Schreibrecht, voller Datenträger – fiel durch. Genau das sind aber die Fälle, die nur auf fremden Servern auftreten. Der Knopf fängt jetzt jeden Fehler und sagt, woran es lag.
Was ich daraus mitnehme: Jeder dieser Fehler war eine Umgebungsdifferenz, und keiner davon war durch mehr Tests auf meinem Rechner zu finden. Was hilft, ist zweierlei – eine nachgestellte Installation aus dem echten Releasearchiv (so wurde ADR 071 gefunden) und die Regel, dass ein Knopf in der Verwaltung niemals mit einer 500-Seite enden darf, sondern mit einem Satz. Das Zweite ist billiger und fängt auch die Fälle, an die ich nicht gedacht habe.
ADR 071 – Ein Buchstabe, der die ganze Funktion unbrauchbar machte (13.09.2026)
Umut hat 1.4.3 installiert, den Updateknopf gedrückt und eine 500-Seite bekommen. Sein Satz dazu: „Das gefällt mir alles ehrlich gesagt nicht. Ich möchte es so einfach wie WordPress." Beides berechtigt.
Nachgestellt wurde es mit einer echten 1.4.3-Installation aus dem Releasearchiv, eigener Datenbank, eigenem Port. Der Fehler stand dann im Protokoll:
Twig\Error\RuntimeError: Key "step" for sequence/mapping with keys
"label, ok, detail" does not exist in "admin/system.html.twig" at line 137
Der Updater über Git nennt einen Schritt step, der neue über das Archiv nannte ihn label, und beide legen ihr Ergebnis unter demselben Sitzungsschlüssel ab. Die Vorlage liest step. Das Update lief vollständig durch – alle neun Schritte in Ordnung, Dateien getauscht, var/ und .env unberührt – und dann starb die Seite, die das berichten sollte. Damit war auch der zweite Knopf unerreichbar, der die Migrationen ausführt, und die Website blieb im Wartungszustand.
Warum kein Test das gefunden hat. Zehn Tests prüfen den Archiv-Updater, und alle prüfen sein Rückgabe-Array. Keiner hat die Seite gerendert, die dieses Array anzeigt. Ein Ergebnis, das stimmt, und eine Anzeige, die es nicht lesen kann – das ist dieselbe Familie wie das og:image, das gespeichert wurde und nie ankam (ADR 056), und wie das Stylesheet, das nichts mehr tat (ADR 064). Die Naht zwischen zwei richtigen Hälften ist die Stelle, an der es bricht. Der Test dazu rendert jetzt /admin/system mit den Schritten eines echten Laufs in der Sitzung; gegengeprüft, indem der Fehler noch einmal eingebaut wurde – der Test wird rot.
Der Schlüssel heißt jetzt in beiden Updatern step. Nicht die Vorlage wurde nachsichtig gemacht: Zwei Dinge derselben Art beschreiben sich gleich, sonst wiederholt sich das beim nächsten Anzeigeort.
Und der zweite Teil von Umuts Satz. Nach dem Tausch stand die Verwaltung mit zwei Karten da, die beide dasselbe wollten – „Migrationen ausführen und freigeben" und „Migrationen stehen aus". Solange das Archiv-Update auf seinen zweiten Schritt wartet, gehört der Knopf an eine Stelle. Zwei Knöpfe, die dasselbe tun, sind eine Frage zu viel.
ADR 070 – Die zwei Lücken, die erst das Veröffentlichen zeigte (13.09.2026)
Umut, kurz nach dem Release: „Wie spiele ich das Update manuell ein?" Die Frage war berechtigt, und die Antwort hatte zwei Lücken.
Erstens: Es gab keinen Weg, Migrationen im Browser auszuführen. Wer die Dateien über FTP hochlädt – und das muss genau einmal jeder tun, dessen Installation die Updatekarte noch nicht kennt –, hat danach neuen Code und eine alte Datenbank. Dafür gab es nur die Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen der Migrationen und führt sie auf Knopfdruck aus. Das Lesen der Liste ist gekapselt: Eine Datenbank, die die Migrationstabelle noch nicht hat, darf nicht der Grund sein, dass dieser Bildschirm gar nicht mehr erscheint.
Zweitens: „Die Releaseseite antwortet nicht" war drei Fehler in einem Satz. Beim Gegenprüfen des frischen Releases holte der Updater von diesem Rechner gar nichts – nicht weil die Leitung zu wäre, sondern weil diesem PHP ein Zertifikatsspeicher fehlt (curl.cainfo leer). Auf einem Webhosting ist das der häufigste der drei Gründe und der einzige, den man in einer Minute behebt; er muss also dastehen. Download merkt sich seither, woran es lag, und der Schritt in der Verwaltung trägt den Grund mit.
Und daraus folgte gleich noch etwas: Bei genau diesem Fehler lohnt der zweite Weg. Der Dateizeiger benutzt die Einstellungen von OpenSSL, und die sind manchmal gesetzt, wo curl nichts findet – auf diesem Rechner funktioniert der Abruf dadurch jetzt. Was nicht passiert, ist das Naheliegende: Die Prüfung wird nicht abgeschaltet. Ein Update, das ohne Zertifikatsprüfung geladen wird, kann unterwegs ausgetauscht worden sein, und dann installiert der Knopf fremden Code.
ADR 069 – Das größte Bild über der Falz ist gar kein Bild (13.09.2026)
Nachgemessen vor dem Livegang, auf der Startseite des Piloten statt auf einer Testseite: Leistung 96 (Desktop) und 98 (Mobil), alles andere 100. Der Grund stand im Bericht: LCP request discovery – das größte Element über der Falz ist dort kein <img>, sondern der Hintergrund des ersten Abschnitts.
Das ist ein struktureller Unterschied, kein Zufall: Ein <img> steht im HTML, der Browser sieht es beim ersten Durchlesen und beginnt zu laden. Ein Hintergrundbild steht im Stylesheet – der Browser erfährt erst von ihm, wenn er das CSS geholt und geparst hat. Die Regel aus ADR 039 („das erste Bild einer Seite lädt sofort") griff genau deshalb nicht: Sie sucht nach einem Bildbaustein, und es gab keinen.
Drei Vorabverweise mit Medienabfragen, nicht ein imagesrcset. Das war die einzige Entscheidung, die wirklich eine war. Ein srcset wählt nach Breite, die Regeln des Compilers wählen nach Gerät – bei einem 1200 px breiten Fenster nähme das eine die 1600er Datei und das andere die 1920er, und der Browser lüde zwei Bilder statt einem. Dann kostet das Vorabholen mehr, als es bringt – genau der Fehler, der in diesem Projekt schon einmal fünf Punkte gekostet hat. Die drei Verweise tragen deshalb dieselben Grenzen wie der Compiler (StyleCompiler::PRELOAD_DEVICES), und die Variantenwahl ist eine einzige öffentliche Methode, die beide Seiten benutzen. Der Test vergleicht nicht nur, dass Verweise da sind, sondern dass jeder Verweis dieselbe Datei meint wie die Regel.
Gemessen danach: Desktop 97, Mobil 99, LCP 1,3 s → 1,1 s. Kein Sprung auf 100 – was bleibt, ist das blockierende Stylesheet (300 ms FCP), und das ist zum großen Teil der Entwicklungsserver ohne Kompression, siehe docs/QUALITY.md.
Und ein Nebenfund, der mehr brachte als der Hauptpunkt. Der Kopf fragte die Mediathek nach demselben Bild, das der Compiler ohnehin schon dreimal gesucht hatte – je Gerät eine Regel, je Regel eine Abfrage. MediaLibrary::find() merkt sich jetzt, wonach es in dieser Anfrage schon gefragt wurde: Startseite 19 → 15 Abfragen, und zwar einschließlich der Abfrage, die der Vorabverweis neu gebracht hat. Der Merkzettel lebt nur so lange wie die Anfrage, also kann er nicht veralten; beim Hochladen und Ändern wird er trotzdem geleert, weil derselbe Aufruf danach noch liest.
ADR 068 – Update aus dem Releasearchiv, in zwei Schritten (13.09.2026)
Umut: „Dann Updater für Hoster optimieren?!" Der Updater aus ADR 035 setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über das Releasearchiv installiert, und die Verwaltung sagte danach „Dieses Verzeichnis ist keine Git-Arbeitskopie". Also kein Update, außer von Hand über FTP. Das ist genau der Fall, für den dieses CMS gedacht ist.
ArchiveUpdate geht denselben Weg wie die Erstinstallation: Archiv laden, Prüfsumme vergleichen, entpacken, Dateien ersetzen, migrieren. Drei Unterschiede trennen ein Update von einer Installation, und jeder davon ist eine Entscheidung:
Was dem Betreiber gehört, wird nicht angefasst. var/, .env, .env.local, die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben stehen. Deshalb wird public/ als einziges Verzeichnis Datei für Datei zusammengeführt statt ersetzt: dort liegen die Dateien der Anwendung und die des Betreibers nebeneinander. Ein rename() des ganzen Ordners wäre eine Zeile weniger Code und hätte die Schriften gelöscht.
Der alte Stand wird beiseitegelegt, nicht gelöscht. Er liegt unter var/update/<Zeitstempel>/alt/, bis jemand ihn entfernt. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er schon bewegt hat, und nimmt den Wartungszustand wieder heraus.
Es sind zwei Knöpfe, und das ist kein Umweg. Der erste ersetzt die Dateien, der zweite führt die Migrationen aus. Dazwischen liegt eine neue Anfrage – denn ein PHP-Prozess, der sich selbst ausgetauscht hat, arbeitet mit den Klassen weiter, die er beim Start geladen hat. Migrationen im selben Durchgang liefen mit dem Code der alten Fassung. Der Wartungszustand bleibt zwischen beiden Schritten an, und die Oberfläche sagt, dass er es tut.
Geprüft wird das ohne Netz. Das Herunterladen ist in Download gewandert – eine Klasse, die genau eine Sache tut und die HTTPS-Pflicht an einer einzigen Stelle durchsetzt. Der Test stellt eine Releaseseite in den Speicher und lässt den Rest echt laufen: echte Zip-Archive, echte Prüfsummen, echte Umbenennungen. Die Frage, um die es dabei geht, ist nicht „läuft der Download", sondern: Steht die hochgeladene Schrift danach noch da? Ein Test, der wirklich git.kant-it.de abfragt, wäre rot, wenn der Server umzieht, und grün, wenn im Archiv Unsinn steht.
Dazu kommt ein Test, der die Ersetzungsliste des Updaters gegen die Mitnahmeliste von scripts/build-release.php hält. Denselben Fehler gab es schon einmal andersherum: migrations.php fehlte im Archiv, und es fiel erst auf Umuts Hosting auf.
ADR 067 – Die Verwaltung auf dem Telefon, und eigene Symbole (13.09.2026)
Umut: „Bitte Backend mobiloptimieren, das Menü ist schlecht." Und kurz darauf: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem Smartphone kommen sonst Emojis."
Zwei Beobachtungen, eine Ursache: Die Verwaltung war für einen breiten Bildschirm gebaut. Zwölf Navigationspunkte nebeneinander ergeben auf 390 px eine Zeile, die seitlich hinausläuft.
Das Menü ist jetzt ein <details>, das ab 760 px Breite aufgeklappt und darunter zugeklappt startet; die Zusammenfassung nennt den Bereich, in dem man gerade steht. Kein Skript, kein Zustand, der verloren gehen kann – und mit Tastatur bedienbar, weil der Browser das mitbringt. Die Ziele sind 44 px hoch, was keine Gestaltungsfrage ist, sondern die Größe einer Fingerkuppe.
Die Symbole waren Unicode-Zeichen (▤ ◫ ☰ ▩ ⚙). Auf dem Schreibtisch sehen sie aus wie Symbole; ein Telefon ersetzt sie durch Emoji – buntes Zahnrad, bunter Briefumschlag, jedes in einem anderen Stil, und nichts davon in der Schrift der Oberfläche. Jetzt sind es zwölf eigene SVG-Pfade in einer Vorlage: eine Linienstärke, currentColor, kein zusätzlicher Request, keine Symbolschrift. Ein Test sucht in der Navigationsvorlage nach Zeichen aus den Emoji-Bereichen und hält jeden verwendeten Namen gegen die Sammlung – ein fehlender Name würde sonst still das Ersatzsymbol zeichnen, und zwei Einträge sähen gleich aus. Genau das war der Fall: „Formulare" und „Benutzer" trugen denselben Pfad.
ADR 066 – Ein Farbfeld, das beides kann (13.09.2026)
Umut: „Wieso kein Colorpicker?" Berechtigte Frage. Die Farbfelder im Designbereich waren Textfelder – wer Grün wollte, musste #2e7d5b wissen.
Der Grund dafür war echt: Ein <input type="color"> kann nur eine Farbe. Die Gestaltung dieses CMS kennt aber auch @akzent – einen Verweis auf die Palette, der sich ändert, wenn die Palette sich ändert. Ein Farbwähler hätte daraus beim ersten Klick einen festen Wert gemacht und die Verbindung stillschweigend gekappt.
Das Textfeld bleibt deshalb maßgeblich, und daneben stehen ein Farbfeld und eine Auswahl der Palettenfarben. Wer im Farbfeld zieht, schreibt einen Hexwert in das Textfeld; wer @akzent tippt, behält den Verweis, und das Farbfeld zeigt daneben nur an, wie diese Farbe gerade aussieht. Aufgebaut wird das von einem kleinen Skript über das vorhandene Feld: Ohne JavaScript bleibt das Textfeld übrig und die Seite vollständig bedienbar.
ADR 056 – Das Vorschaubild beim Teilen, und die robots.txt als Bot-Steuerung (13.09.2026)
Umut: „Hast du auch schon SEO und Siteindex usw. übernommen?“ Der Abgleich mit dem Original war die Antwort – und er förderte einen Fehler der eigenen Software zutage.
Das Feld „Vorschaubild beim Teilen“ gab es seit dem SEO-Paket, ausgegeben wurde es nie. Im Kopf stand {% if socialImage %}, gesetzt hat socialImage kein Controller. Damit hatte keine mit KantSites gebaute Seite ein og:image – beim Teilen in WhatsApp, LinkedIn oder Slack erschien eine Karte ohne Bild. Jetzt wird es aufgelöst: erst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild, als absolute Adresse und in der Variante bis 1600 px. Dasselbe Bild steht im strukturierten Datensatz des Beitrags. Ohne Bild gibt es twitter:card: summary statt der großen Karte, die sonst eine leere Fläche wäre. Dazu og:site_name und og:locale, die schlicht fehlten.
Die robots.txt war zu eng gefasst. Das Original pflegt eine Liste, welche KI-Crawler die Website lesen dürfen (GPTBot, ClaudeBot, PerplexityBot …) und welche nicht (Bytespider, Diffbot, omgili, ImagesiftBot) – rund siebzig Zeilen in Blöcken je Bot. Das Feld für eigene Zeilen nahm dreißig und warf Leerzeilen weg, machte also aus Blöcken einen Klumpen. Jetzt 200 Zeilen, Leerzeilen bleiben als Trenner erhalten. Die erlaubten Anweisungen bleiben dieselben: Kommentar, User-agent, Allow, Disallow, Crawl-delay.
Kein Sitemap-Index. Das Original liefert einen (drei Teilkarten von Yoast), KantSites eine einzelne Datei mit allen Adressen. Ein Index ist erst ab 50.000 Adressen oder 50 MB vorgeschrieben; bei 35 Adressen wäre er eine Datei mehr ohne jeden Unterschied für eine Suchmaschine. Aufgeteilt wird, wenn es eine Website gibt, die das braucht – und dann automatisch, nicht als Einstellung.
ADR 059 – Cloudflare Turnstile: optional, und der Preis steht dabei (13.09.2026)
Umut: „bei Formularen hätte ich gerne optional Cloudflare." Eingebaut sind drei Prüfungen ohne Dritte – ein verstecktes Feld, ein signierter Zeitstempel, fünf Absendungen je Absender und Stunde. Sie halten ab, was ein Skript ausfüllt; gegen jemanden, der einen echten Browser fernsteuert, helfen sie nicht.
Turnstile und nicht reCAPTCHA: keine Cookies, keine Google-Anmeldung, keine Verhaltensauswertung über Websites hinweg. Ein Dritter bleibt es trotzdem – die IP-Adresse des Absenders geht an Cloudflare, und das gehört in die Datenschutzerklärung.
Drei Entscheidungen, die nicht offensichtlich sind:
Aus, bis jemand sie einschaltet. Eine Website ohne Spamwelle soll keinen Dritten einbinden. Eingeschaltet ohne Schlüssel wird abgelehnt: das wäre ein Formular, das jede Anfrage verschluckt.
Antwortet Cloudflare nicht, gilt die Absendung als bestanden. Ein Ausfall bei einem Dritten darf keine Kundenanfrage kosten. Spam ist ärgerlich, eine verlorene Anfrage ist teurer.
Die Sicherheitsrichtlinie öffnet sich nur dort, wo ein Formular steht – Skript und Rahmen von challenges.cloudflare.com, und das auch nur auf den betroffenen Seiten. Jede andere Seite behält script-src 'none'.
Der Preis steht in der Verwaltung: das Widget braucht JavaScript. Ohne Skript ist das Formular dann nicht absendbar – genau die Art von Verschlechterung, die dieses Projekt sonst vermeidet. Deshalb steht im Formular ein <noscript>-Hinweis mit dem Weg über E-Mail.
ADR 065 – Die Verwaltung muss nicht /admin heißen (13.09.2026)
Umut: „Erstelle mal Möglichkeit, Backend anzupassen. Also URL, Admin auf irgendwas."
/admin ist die erste Adresse, die ein Scanner probiert, und jeder Anmeldeversuch, der dort ankommt, kostet Rechenzeit und Logzeilen. Ein anderer Name nimmt der Maschinerie das Ziel. Er ersetzt kein gutes Passwort – die Anmeldesperre nach fünf Versuchen bleibt die eigentliche Verteidigung –, aber er macht die Website für automatisierte Angriffe uninteressant.
Der Name steht in der Umgebung, nicht in der Datenbank. Routen stehen fest, bevor die erste Datenbankabfrage läuft; eine Adresse, die erst beim Rendern bekannt wird, käme zu spät. ADMIN_PATH füllt einen Parameter, der als Präfix in jeder Verwaltungsroute steht (#[Route('/%kant.admin_path%/media')]), in der Firewall, in den Schutzkopfzeilen und in der Statistik.
Drei Dinge, die dabei nicht offensichtlich sind:
Die robots.txt nennt den Namen nicht mehr. Sie ist öffentlich und das erste, was ein Angreifer liest – ein Disallow: /stiller-eingang wäre ein Wegweiser. Beim Standardnamen bleibt die Zeile, weil diese Adresse ohnehin jeder kennt; bei jedem anderen entfällt sie. Vom Indexieren hält die Verwaltung ihr eigener Kopf X-Robots-Tag: noindex ab.
Die Auffangroute für Seiten steht jetzt zuletzt. Sie schließt per Regex /admin aus – einen frei wählbaren Namen kann sie nicht kennen. Statt die Regex dynamisch zu machen, lädt routes.yaml erst alle benannten Routen und dann die Auffangroute. Das ist ohnehin die richtige Reihenfolge.
Der Befehl räumt den Zwischenspeicher auf, aber nicht den, in dem er selbst läuft. Die Routen tragen den Namen einbetoniert; ohne Aufräumen gälte der alte weiter. Wer sich dabei den Boden unter den Füßen wegzieht, endet mit einem Fehler, obwohl alles geklappt hat – also bleibt die laufende Umgebung stehen, und der Befehl sagt, wie man sie leert.
php bin/console cms:admin:path zeigt die aktuelle Adresse, mit Argument ändert er sie – nach einer Prüfung, dass der Name frei ist und keine Seite verdeckt.
ADR 064 – Ein Stylesheet, das nichts mehr tat (13.09.2026)
Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser lösen." Das war kein Gestaltungswunsch, sondern ein Fehlerbericht: Der Einwilligungsdialog stand ungestaltet unter dem Fuß, linksbündig, ohne Rand, ohne Hintergrund.
Die Ursache lag drei Veröffentlichungen zurück. Beim Entfernen alter Regeln war zweimal der Selektorrest stehen geblieben:
.site-header
.site-footer
/* Kommentar */
.legal-bar{padding:16px 20px;text-align:center;…}
CSS liest darüber hinweg: Der offene Selektor nimmt den nächsten mit, und aus zwei Regeln wird eine, die nirgends greift. Dasselbe war der .consent-bar-Regel passiert. Ein Stylesheet beschwert sich nicht – es wirkt nur nicht mehr, und beides blieb unbemerkt, weil kein Test das Aussehen dieser beiden Leisten prüft.
Repariert, und ein Test liest jetzt jede CSS-Datei zeilenweise: Was wie ein Selektor beginnt, muss auch wie einer enden. Er findet den Fehler in beiden Fassungen, geprüft gegen den alten Stand.
ADR 063 – Der Bildbetrachter: ein Verweis, den ein Skript veredelt (13.09.2026)
Umut: „Bilder vergrößern Funktion, ein Popup-Viewer – gerne auch auswählbar wie bei WordPress, ob man bei Klick Galerie anschauen möchte."
Auswählbar heißt hier: Jedes Bild hat unter „Beim Klick" drei Möglichkeiten – nichts tun, vergrößern, zu einer Adresse führen –, und eine Galerie hat einen Schalter „Bilder vergrößerbar".
Die Grundlage ist ein gewöhnlicher Verweis auf die große Bildfassung. Ohne JavaScript öffnet ein Klick sie, zurück führt die Zurück-Taste; nichts ist kaputt. Das Skript – geladen nur auf Seiten, die tatsächlich ein vergrößerbares Bild enthalten – fängt den Klick ab und zeigt das Bild über der Seite, mit Blättern innerhalb einer Galerie.
Benutzt wird <dialog>: Fokusfang, Schließen mit Escape und die Auszeichnung als Dialog bringt der Browser mit. Eine nachgebaute Fassung aus <div> und Tastaturabfragen wäre mehr Code und weniger zugänglich. Mit gedrückter Zusatztaste bleibt es ein normaler Verweis – wer ein Bild in einem neuen Reiter öffnen will, soll das können.
ADR 062 – Zwei Dinge, die am Rand kleben: Knopf nach oben und Rechtszeile (13.09.2026)
Der Knopf „nach oben" ist ein Anker, kein Skript. <a href="#seitenanfang"> springt an den Seitenanfang – das kann ein Browser seit jeher allein. Eingeblendet wird er über eine Scroll-Zeitachse (animation-timeline: scroll(root)), dieselbe Technik wie bei den Einblendungen; wo es sie nicht gibt, steht er von Anfang an da. Sichtbar und nutzbar ist besser als unsichtbar. Alles daran ist einstellbar – Seite, Form, Größe, Farben, Schwelle, Beschriftung –, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung passen muss. Solange der Einwilligungsdialog offen steht, tritt er zurück.
Die Rechtszeile wird ein Baustein. Bisher hängte das System sie als eigene Leiste unter den Fuß: auf einer Website mit dunklem Fuß ein heller Streifen, den niemand dort haben wollte. Als Baustein steht sie im Fußbereich, erbt dessen Farben und lässt sich wie jeder andere Baustein gestalten. Die eingebaute Leiste erscheint weiterhin – aber nur, wenn die Seite den Baustein nicht enthält. Die Pflicht, Impressum und Datenschutz von jeder Seite aus erreichbar zu halten, bleibt damit erfüllt, ohne dass zweimal dasselbe dasteht.
ADR 061 – Die Vorschau zeigt nie eine Seite ohne ihre Gestaltung (13.09.2026)
Umut, mit Blick auf einen Screenshot des Editors: „im Screenshot sind die Buttons nicht gleiche Farbe, es sollte exakt nachladen, die Vorschau soll wie das echte aussehen."
Nachgemessen: die Farben sind identisch. Knopf, Hero-Hintergrund, Hintergrundbild und Textfarbe geben in der Vorschau und auf der veröffentlichten Seite exakt dieselben berechneten Werte. Was auf dem Bild zu sehen war, war ein Zwischenzustand – links oben stand „Vorschau wird aktualisiert …".
Der Zwischenzustand war trotzdem ein Fehler, und ein langer: 1440 Millisekunden lang war die Vorschau sichtbar und ungestaltet, gemessen an der nachgebauten Startseite. Der Grund liegt in einer Zeile: die erzeugten Stile wurden im load-Ereignis des Rahmens angewandt – und das feuert erst, wenn das letzte Bild geladen ist. Bei sechzehn Bildern sieht man bis dahin die Seite ohne eine einzige Farbe.
Drei Änderungen, jede aus einem eigenen Fehlversuch:
- Angewandt wird, sobald das Dokument geparst ist, nicht wenn es fertig geladen ist. Für
srcdocgibt es dafür kein verlässliches Ereignis, also wird Bild für Bild nachgesehen, ob der Kopf schon da ist. - Der Rahmen bleibt verdeckt, bis die Gestaltung sitzt. Eine leere Fläche für einen Moment ist besser als eine falsche Seite.
- Nur das neue Dokument wird gestaltet. Der erste Anlauf sah beim Warten das alte Dokument – es trägt dieselben Kennzeichen –, gestaltete es, schaltete sichtbar, und
srcdocersetzte es gleich darauf durch den ungestalteten neuen Inhalt. Der Fehler war damit derselbe wie vorher, nur schneller.
Gegen einen Rückfall prüft die Editorkontrolle nicht die Zeit, sondern das Verhalten: der Rahmen muss beim Aufbau verdeckt werden und danach wieder sichtbar. Eine Zeitmessung hätte auf der kleinen Testseite nichts gefunden – das Fenster ist dort zu kurz.
ADR 060 – Die Fehlerseite eines Formulars ist dieselbe Seite (13.09.2026)
Beim Einbau von Turnstile gefunden: wer ein Pflichtfeld vergaß, bekam die Seite ohne Gestaltung zurück. Der Formularcode baute sie selbst ein zweites Mal auf und ließ dabei alles weg, was er nicht kannte – Theme, Blockstile, Schriften, das Stylesheet-Bündel. Zwei Renderwege für dieselbe Seite, und der zweite hinkte hinterher, seit es den ersten gibt.
Jetzt geht auch dieser Weg durch den Seitencontroller. Die Meldungen und die eingetippten Werte reisen als Anfrageattribut mit; die Seite entsteht genau einmal, mit allem, was dazugehört. Eine Sonderregel blieb nötig: die Startseite leitet von ihrem eigenen Pfad auf / um, und diese Umleitung hätte die Meldungen verworfen – sie gilt für diesen Weg nicht.
ADR 058 – Bilder in der Größe, in der sie erscheinen (13.09.2026)
Gemessen auf der nachgebauten Startseite: ein Telefon lud das Hintergrundbild des Hero mit 1920 Pixeln und 189 KB. Zu sehen war davon nie mehr als ein Viertel. Die Ursache ist bauartbedingt: wer im Editor ein Hintergrundbild wählt, sitzt am Schreibtisch und setzt es unter „Desktop“; kleine Geräte erben diese Regel, und CSS kennt kein srcset.
Die Regeln stehen aber ohnehin je Gerät da. Der Kompilierer ergänzt deshalb für Tablet und Telefon dieselbe Bildwahl, die entstünde, wenn jemand sie einzeln gesetzt hätte – samt Verlauf und Abdunkelung, denn ohne sie stünde heller Text plötzlich auf einem ungedämpften Bild. Ein ausdrücklich gesetztes Bild wird nie ersetzt. 189 KB → 40 KB auf dem Telefon.
Dasselbe Thema bei <img>: jedes Bild behauptete, 800 Pixel breit zu erscheinen – auch ein Logo, das 190 Pixel breit ist. Jetzt gilt: eine im Block gesetzte feste Breite ist die Antwort; sonst steht bei verzögert geladenen Bildern sizes="auto", womit der Browser selbst nachmisst (Rückfallangaben dahinter für Safari). Das vorgezogene Bild behält die Annahme, weil seine Größe zum Zeitpunkt der Anfrage noch nicht feststeht.
Und die Eile lag auf der falschen Stelle: fetchpriority="high" bekam das erste Bild überhaupt – das Logo im Kopfbereich. Es bekommt es jetzt das erste Bild im ersten Abschnitt des Inhalts; Bilder im Kopfbereich laden weiterhin sofort, aber ohne Vorrang.
Was nicht blieb: ein Vorabladen des Hero-Hintergrunds (<link rel="preload" as="image">). Der Gedanke stimmt – ein Bild im Stylesheet wird spät entdeckt –, die Messung nicht: mobil 95 → 90. Das Bild nimmt genau die Bandbreite weg, die der Text für den größten Aufbauschritt braucht. Wieder entfernt.
ADR 057 – Schriften vom eigenen Server (13.09.2026)
Eine Schrift von fonts.googleapis.com kostet zweierlei. Technisch: der Browser holt erst das Stylesheet, findet darin die Adresse der Schriftdatei und holt dann diese – zwei fremde Verbindungen nacheinander, gemessen 820 ms, in denen keine Zeile in der richtigen Schrift steht. Rechtlich: ein Dritter erfährt die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland Abmahnungen (LG München I, 3 O 17493/20).
Deshalb: einmal holen, selbst ausliefern. Ein Knopf im Designbereich (und cms:fonts:fetch auf der Konsole) lädt die gewählten Schnitte als WOFF2 nach public/assets/fonts, schreibt die @font-face-Regeln daneben und meldet, was geladen wurde. Die Lizenz erlaubt das ausdrücklich – Google Fonts stehen unter OFL oder Apache 2.0.
Drei Entscheidungen dabei: Kein neues Paket – geholt wird mit curl, ersatzweise über allow_url_fopen, genau wie der Installer es tut; eine zusätzliche Abhängigkeit würde jedes Update auf einem Hosting ohne Composer scheitern lassen. Nur Latein – Kyrillisch und Vietnamesisch wären zwei Drittel der Bytes für nichts. Nicht beim Seitenaufruf – ein Besucher wartet nie auf einen fremden Server, auch nicht mittelbar. Liegen die Dateien nicht, bleibt es beim Verweis auf Google statt einer Seite ohne Schrift.
ADR 056 – Vier Stylesheets werden eines, und der Webserver bekommt Anweisungen (13.09.2026)
Vor dem Livegang gemessen, nicht geschätzt. Jede der vier CSS-Dateien hält das Zeichnen der Seite auf, zusammen 46 KB. Sie sind getrennt, weil sie beim Schreiben Verschiedenes tun; beim Ausliefern ist das kein Argument. Ein Bündel trägt die Prüfsumme seines Inhalts im Namen, wird beim ersten Aufruf nach einer Änderung erzeugt und ohne Kommentare geschrieben. Minifiziert wird bewusst nur das: Kommentare und Einrückung. Ein Minifier, der Selektoren umbaut, spart Zeichen und kostet einen Abend Fehlersuche – in calc(100% - 20px) und .a :hover trägt ein Leerzeichen Bedeutung. Fehlt das Schreibrecht, werden die Quellen einzeln geladen: langsamer, aber heil.
Dazu eine public/.htaccess, die diesen Namen verdient: Kompression (mod_deflate/mod_brotli), ein Jahr Cache für alles, was seine Fassung oder Kennung in der Adresse trägt – Stylesheets und Skripte bekommen dafür ?v= mit der Programmfassung –, und ein Riegel vor Dateien, die niemanden angehen. HTTPS-Umleitung und HSTS stehen auskommentiert mit dem Grund dabei: eine Umleitung ohne Zertifikat ist eine Endlosschleife, und HSTS lässt sich ein halbes Jahr lang nicht zurücknehmen.
Der Entwicklungsserver sendet jetzt dieselben Kopfzeilen und komprimiert ebenso. Ohne das misst man hier Größen, die es im Betrieb nie gibt: dieselbe Startseite ging von 90 auf 96 Punkte, ohne dass sich an ihr etwas geändert hätte.
ADR 055 – Das Symbol der Website entsteht aus einem Bild der Mediathek (13.09.2026)
Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon ist der einzige Teil der Gestaltung, den ein Besucher sieht, bevor die Seite geladen ist – und bisher war es das mitgelieferte Zeichen, für jede mit KantSites gebaute Website dasselbe.
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist quadratisch – ein breites Logo würde gestaucht oder zur Briefmarke.
Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter /favicon.png und /apple-touch-icon.png, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
Nebenwirkung, die die Tests fanden: SiteSettings fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und vor der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene TableNotFoundException liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler.
ADR 054 – Unterstrichene Links: nur im Fließtext, und dort abschaltbar (13.09.2026)
Umut: „und links haben alle underlined“. Stimmte: a hatte keine eigene Textdekoration, also entschied der Browser – und der unterstreicht alles. Sichtbar wurde das an Stellen, wo ein Unterstrich nur wie ein Fehler aussieht: im „Ansehen“-Streifen über einem Kartenbild, im Menü bei der aktuellen Seite, in der Rechtszeile des Fußes, neben dem Telefonsymbol im Kontaktblock.
Die Trennlinie ist nicht Geschmack, sondern Funktion: ein Link mitten im Fließtext ist ohne Unterstrich nur an seiner Farbe zu erkennen – für Menschen mit Farbsehschwäche also gar nicht (WCAG 1.4.1). Ein Link, der als Karte, Knopf, Menüeintrag oder Kontaktzeile mit Symbol auftritt, ist an Form und Ort erkennbar und braucht keinen.
Also: strukturelle Links ohne Unterstrich (beim Überfahren erscheint er), Fließtext mit – und für den Fließtext ein Schalter im Designbereich statt einer stillen Entscheidung, mit dem Hinweis, was man sich damit einhandelt. Die aktuelle Seite im Menü wird über das Schriftgewicht kenntlich und nicht mehr über die Akzentfarbe: die reichte im dunklen Fußmenü nicht für den Kontrast – gefunden von der Browserprüfung, nicht von mir.
ADR 053 – Eine Website ohne Beiträge redet nicht von Beiträgen (13.09.2026)
Umut: „Können wir Beiträge optional machen? Wenn's keine gibt, überall ausblenden – ist das schon so?“ Teilweise: die Sitemap ließ das Archiv weg, llms.txt auch. Der Rest nicht – /beitraege antwortete mit einer leeren Seite, /feed.xml mit einem Feed ohne Einträge, jede Seite verwies im Kopf auf diesen Feed, und ein Beitragslisten-Baustein zeigte öffentlich den Kasten „Hier erscheinen die Beiträge, sobald welche veröffentlicht sind“.
Eine Adresse, die nichts zeigt, ist kein Inhalt, sondern eine Baustelle mit eigener Adresse. Archiv und Feed antworten deshalb mit 404, solange sie nichts zu zeigen haben – das gilt auch für eine einzelne Kategorie, deren letzter Beitrag gelöscht wurde: der Begriff bleibt als wiederverwendbare Beschriftung bestehen, sein Archiv verschwindet. Der Verweis auf den Feed steht nur im Kopf, wenn es ihn gibt. Der Hinweiskasten bleibt – aber nur im Editor, denn dort erklärt er die leere Stelle, während er öffentlich nur ein Versprechen wäre.
Beim Nachmessen fiel eine zweite Sache auf, die nichts mit Beiträgen zu tun hat: die Beitragsliste fragte je Beitrag nach seinem Bild und seinen Begriffen. Zwölf Beiträge kosteten 24 Abfragen, und neighbours() (Vor/Zurück) lädt bis zu 500 Beiträge. Bilder und Begriffe kommen jetzt in je einer Abfrage für die ganze Liste: die Startseite des Nachbaus fiel von 22 auf 16 Abfragen.
ADR 052 – Einblenden beim Scrollen, ohne eine Zeile JavaScript (13.09.2026)
Umut: „Effekte gibt es keine, sind die aktiv aktuell?“ Nachgemessen statt geraten: die Zeigereffekte liefen alle (Karte −8 px, Bild 1,06×, Symbolkarte −4 px, Knopf −2 px, Titel in Akzentfarbe). Die Einblendungen beim Laden gab es seit Dokumentschema v3 – und benutzt hatte sie niemand. Ein Werkzeug, das im Kasten bleibt, ist kein Werkzeug.
Der Grund, warum es im Kasten blieb, war ein echter: eine Animation, die beim Laden läuft, ist unterhalb der Falz wertlos – sie ist vorbei, bevor jemand hinsieht. Öffentliche Seiten laufen ohne JavaScript, also gab es bisher nichts, was den Scrollstand kennt.
Inzwischen kennt CSS ihn. animation-timeline: view() bindet den Ablauf einer Animation an die Sichtbarkeit des Elements: dieselben Keyframes, aber sie laufen, wenn der Abschnitt in den Blick kommt. Dreifach abgesichert: hinter der Bewegungsschranke, hinter @supports (Firefox und Safari behalten heute die Fassung von vorher – Einblenden beim Laden, nichts fehlt), und mit einem Bereich, der endet, bevor der Abschnitt in der Bildschirmmitte steht.
Und dann hat die Prüfung etwas gefunden, das ich selbst gebaut hatte. Eine Scroll-Zeitachse ist umkehrbar: ein Abschnitt außerhalb des Blicks steht wieder am Anfang der Kurve. Mit der ursprünglichen Kurve (opacity: .35) hieß das: alles unterhalb der Falz ist dauerhaft blass – und blasser Text ist kein Effekt, sondern schlechterer Kontrast. axe meldete prompt fünfzehn Verstöße. Der Versuch mit 0,85 half nicht: aus #3d4d5c wird bei 85 Prozent #78838d, also 3,86:1 statt 7,1:1.
Auf dem Scrollweg wird deshalb keine Deckkraft animiert, nur Bewegung. 20 px für „rise“, 10 px für „fade“, Deckkraft bleibt 1. Der Effekt trägt trotzdem, und der Text ist an jeder Stelle lesbar – auch wenn er gerade nicht zu sehen ist. Beim Laden (ohne Scroll-Zeitachse) bleiben die alten Kurven mit Deckkraft, weil sie dort einmalig laufen und nicht zurückspulen.
Benutzt wird es jetzt auch: alle drei Beitragsvorlagen bringen die Einblendung je Abschnitt mit, und der Nachbau von kant-it.de hat sie auf jeder Seite – im Hero gestaffelt, darunter je Abschnitt.
ADR 051 – Vier Darstellungen für dieselbe Beitragsliste (13.09.2026)
Umut wollte auch für die Auflistung mehrere Formen. Dieselben Daten, dasselbe Markup – nur eine Klasse wechselt. Eine Darstellung kostet dadurch ein paar Regeln CSS und keine Zeile PHP.
- Karten – Raster mit Bild oben. Für eine Übersicht.
- Liste – Bild links, Titel und Auszug rechts. Für ein Archiv.
- Kompakt – nur Titel und Datum, durch Linien getrennt, ohne Bilder. Für eine Randspalte oder ein langes Verzeichnis.
- Wechselseitig – großes Bild, Text daneben, und bei jedem zweiten Beitrag die Seiten getauscht.
Die erste Fassung von „Wechselseitig" war ein umgedrehter Flexkasten und sah auch so aus. Umut hat es zu Recht hässlich genannt. Was gefehlt hat, war nicht die Umkehrung, sondern alles andere: das Bild sitzt jetzt in einer Karte mit Ecken und weichem Schatten in festem Verhältnis 16:10, damit die Reihen gleich hoch sind; der Text bekommt eine Nummer aus einem CSS-Zähler, eine begrenzte Zeilenlänge und einen sichtbaren Verweis; zwischen den Reihen liegt eine feine Linie. Beim Zeigen zieht das Bild auf und der Titel nimmt die Akzentfarbe – die Reihe selbst bleibt stehen, denn sie ist keine Karte.
Zwei Details, die der Bau gelehrt hat: die Karte ist das Listenelement, also zählt :nth-child auf ihr und nicht eine Ebene tiefer – die erste Fassung wechselte deshalb gar nicht. Und die Umkehrung läuft über order der beiden Spalten statt über row-reverse, weil sich damit auf schmalen Bildschirmen sauber wieder Bild-über-Text ergibt.
Die Nummer kommt aus einem Zähler, nicht aus dem Inhalt. Auf Seite zwei beginnt sie wieder bei 01. Das ist ehrlich: der Beitrag weiß nicht, der wievielte er ist, und eine Nummer, die das behauptet, wäre erfunden.
ADR 050 – Drei Aufbauten für einen Beitrag (13.09.2026)
Umut: „ich hätte gerne mehrere Designs zur Auswahl, 2–3 Stück für Beiträge.“
Warum das bei Beiträgen sinnvoll ist und bei Seiten nicht: bei einer Seite weiß vorher niemand, was daraus wird – ein leeres Dokument ist dort die ehrliche Voreinstellung. Ein Beitrag hat dagegen fast immer dieselbe Form: Kopf, Text, Bilder, ein Weg weiter. Wer jedes Mal von vorn anfängt, baut jedes Mal etwas leicht anderes, und nach zwanzig Beiträgen sieht ein Archiv aus wie zwanzig Websites.
Die drei: Bildkopf (Bild über die volle Breite, Titel darauf – für Projekte), Klassisch (Titel und Vorspann schmal, Bild darunter – für lange Texte), Magazin (Titel neben den Eckdaten – wenn Kunde, Leistung und Jahr zählen). Gewählt wird beim Anlegen, im selben Formular wie Titel und Adresse.
Es sind Startpunkte, keine Schablonen. Jeder Block lässt sich danach verschieben, ändern und löschen wie jeder andere. Festgelegt wird nur die erste Entscheidung – und die ist die, die am schwersten nachzuholen ist.
Kein Aufbau verlässt sich auf ein Bild, das es noch nicht gibt. Wo ein Beitragsbild hingehört, steht ein Platzhalter; der dunkle Kopf ohne Bild sieht schlicht aus statt kaputt. Ein Aufbau, der erst nach dem ersten Upload funktioniert, wäre beim Anlegen wertlos.
Und die Regel, die dabei gefallen ist: der erste Textblock eines Abschnitts wurde automatisch zur Kleinbeschriftung in Akzentfarbe. Das gestaltet nach Position statt nach Absicht – im Fußbereich wurde daraus roter Versalientext für den Firmennamen, in der Magazin-Vorlage aus dem Einstiegsabsatz eine Kleinbeschriftung. Beides sah aus wie ein Fehler, weil es einer war. Wer eine Beschriftung will, stellt sie ein; dafür gibt es den Designbereich. Die Demoseite verliert dadurch ihre Versalienzeilen und wirkt ruhiger – dasselbe Ergebnis wie beim Rahmen der Spalten.
ADR 049 – Die Beitragsseite, und warum das kein Plugin wurde (Dokumentschema v18, 13.09.2026)
Umut: „Beitragsseite gefällt mir nicht, sollte wie bei kant-it.de sein oder besser. Falls nötig Plugin bauen.“
Nachgesehen: die Originalseite besteht aus Kopfbild, Text, Bildergalerie, Vor/Zurück zwischen Projekten und einem Abschlussaufruf. Galerie und Aufruf gab es als Bausteine längst – sie waren nur nicht benutzt. Die Navigation zwischen Beiträgen gab es nicht.
Warum das kein Plugin geworden ist. Ein Plugin ist in diesem CMS ein Bausteintyp mit Namensraum, dessen Eigenschaften der Kern aufbewahrt, aber nicht rendert – genau damit eine abgeschaltete Erweiterung keine Seite unlesbar macht. Ein Plugin kann also nichts ausgeben, solange es keine Laufzeit dafür gibt (Arbeitspaket H). Und die Nachbarn eines Beitrags sind keine Fremdfunktion: sie stehen in derselben Tabelle wie die Beitragsliste, die der Kern schon füllt. Ein Plugin dafür wäre eine Hülle um eine Kernabfrage gewesen. Also ein Baustein: postnav, Dokumentschema v18.
Die Regel der Beitragsliste gilt auch hier: der gespeicherte Inhalt kennt keinen Beitrag. Gespeichert sind zwei Beschriftungen, mehr nicht. Welcher Beitrag vorher und nachher kommt, entscheidet der Renderer beim Ausliefern – sonst könnte ein altes Dokument auf einen Beitrag zeigen, den es nicht mehr gibt oder der nie veröffentlicht wurde.
Gesucht wird innerhalb derselben Kategorie: von einem Projekt führt man zum nächsten Projekt, nicht zur nächsten Pressemitteilung. Am Rand wird nicht umgebrochen – der neueste Beitrag hat keinen Vorgänger. Ein Ring wäre bequem zu programmieren und eine Falle für den Leser, der glaubt, er sei am Anfang.
Dabei zwei Dinge gelernt: present() gibt für eine Liste bewusst keine Kennung heraus, nur Titel und Adresse – die Nachbarsuche vergleicht deshalb über die Adresse. Und die Abfrage kostet etwas, also läuft sie nur, wenn die Seite den Baustein wirklich enthält.
Was ich zusätzlich geändert habe, ohne gefragt zu werden: die Absätze der übernommenen Projekttexte begannen mit dem wiederholten Titel und der Zwischenüberschrift, weil beides im selben Absatz stand – 20 Beiträge betroffen. Und schmale Abschnitte standen am linken Rand statt in der Mitte: max-width ohne margin:auto ist eine halbe Einstellung.
ADR 047 – Ein Slider, der von selbst weiterblättert (13.09.2026)
Umut wollte die Autorotation. Die Regel „öffentliche Seiten laufen ohne JavaScript“ steht seit dem ersten Tag, und sie bleibt – mit einer Ausnahme, die es schon gab: die Tabs laden ein kleines Skript, aber nur auf Seiten, die Tabs enthalten. Der Slider folgt genau diesem Muster.
Die Einstellung sind Sekunden, kein Häkchen. 0 heißt aus, unter drei Sekunden wäre es keine Hilfe mehr, sondern Hektik. Die Zahl steht im Baustein, nicht im Skript.
Was das Skript nicht tut, ist der interessantere Teil:
- Es läuft gar nicht, wenn das System weniger Bewegung verlangt.
- Es hört endgültig auf, sobald jemand tippt, klickt, wischt oder von Hand scrollt. Nicht „pausiert kurz“ – aus. Wer selbst blättert, hat entschieden.
- Es pausiert, solange der Zeiger darin ist, solange etwas darin den Fokus hat, solange der Tab im Hintergrund liegt und solange der Slider nicht zu sehen ist.
Ohne das Skript bleibt alles benutzbar: wischen, scrollen, Tastatur, Punkte. Es kommt nur eine Bequemlichkeit dazu, es hängt nichts davon ab.
Und ein Fehler beim Bauen, der zeigt, warum man so etwas nachmisst: die erste Fassung rechnete das Sprungziel aus offsetLeft und zog zusätzlich den Offset des Sliders ab – bei einem Slider, der selbst der Bezugsrahmen ist, kam dabei −78 heraus, also gar keine Bewegung. Gemessen wird jetzt über die Bildschirmrechtecke. Der Test dazu ist kein Schnappschuss, sondern ein Verlauf: 0 → 666 → 1332 → 1998 in drei Schritten, und nach einem Mausrad-Ereignis bleibt der Wert stehen.
ADR 048 – Eine Beitragsliste darf blättern (13.09.2026)
Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig, und es widersprach der eigenen Regel aus Arbeitspaket M: eine feste Obergrenze ist kein Blättern, sie verschweigt, dass etwas fehlt. Für die Verwaltung war das behoben, für die Beitragsliste einer Seite nicht.
Der Unterschied ist absichtlich und heißt jetzt paginate: eine Auswahl auf der Startseite zeigt die neuesten vier und hört auf – dort will niemand blättern, dort will man weiter zur Übersicht. Eine Übersichtsseite blättert und sagt dabei, wie viele es insgesamt sind.
Zwei Kleinigkeiten, die dabei wichtig waren: eine blätternde Liste nimmt die Seitenzahl aus der Anfrage, eine Auswahl immer die erste – sonst würden zwei Listen auf derselben Seite einander umblättern. Und eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren.
Beim Bauen zum zweiten Mal dieselbe Falle: die Seitenzahl war in der Schließung, die die Blöcke durchläuft, nicht gebunden – genau wie vorgestern beim Filter für Überschriften. Diesmal fiel es sofort auf, weil die Seite mit 500 antwortete statt still das Falsche zu tun.
ADR 046 – Der eigene Blick: was ich vor einem Livegang ändern würde (13.09.2026)
Umut: „schau selbst drüber, ohne zu vergleichen – würdest du so online gehen?“ Antwort: so nicht. Fünf Dinge sind mir aufgefallen, und drei davon waren Fehler im CMS, nicht im Inhalt.
1. Verborgener Text stand sichtbar auf der Seite. .visually-hidden gibt es in admin.css, aber nicht in site.css – auf jeder öffentlichen Seite waren damit die Zusätze zu lesen, die nur für Vorlesesoftware gedacht sind: „Bewertung: 5 von 5“ neben den Sternen, „(Pflichtfeld)“ hinter jedem Formularfeld. Das ist der peinlichste Fund der Reihe, weil er seit dem ersten Formular dasteht und in keinem Test auffiel: axe prüft, ob etwas angesagt wird, nicht ob es fälschlich sichtbar ist.
2. Jede Spalte war ein Kasten. .block-column brachte Rahmen, Ecken und 30 px Innenabstand mit. Eine Spalte ist aber ein Layoutmittel – wer damit zwei Textblöcke nebeneinander stellt, bekam zwei umrandete Kästchen und fragt sich, warum seine Seite aussieht wie ein Formular. Die Karte ist jetzt das, was sie sein sollte: eine Einstellung, kein Standard. Die Demoseite sieht dadurch ruhiger aus, nicht ärmer.
3. Der Knopf im Aufruf-Band war unsichtbar. Er nahm die Tintenfarbe – auf einem dunklen Band also Tinte auf Tinte, ausgerechnet beim Handlungsaufruf. Aufruf-Band und Formular nehmen jetzt die Akzentfarbe.
4. Gestapelte Karten klebten aneinander. In einer Zeile sorgt das Raster für Abstand, untereinander gab es keinen. Aufeinanderfolgende Karten bekommen 18 px.
5. Und mein eigener Inhalt: die linke Spalte im Kontaktbereich war zur Hälfte leer, weil sie sich auf die Höhe des Formulars zog. Sie steht jetzt oben und trägt drei Karten, die die Frage beantworten, die man an dieser Stelle hat: was passiert, wenn ich abschicke?
Was ich bewusst gelassen habe: die Einwilligung liegt weiterhin als Kasten mitten auf der Seite. Das ist richtig so – sie ist eine Entscheidung, keine Verzierung –, aber sie verdeckt auf jedem Bildschirmfoto den Inhalt dahinter. Wer die Seite abnimmt, sollte sie einmal wegklicken, bevor er urteilt.
ADR 045 – Der Zeigereffekt, und warum Feinschliff kein Zusatz ist (13.09.2026)
Umut: „es gibt keinen Mausover-Effekt wenn man auf einen Beitrag geht, das fehlt … grob siehts ähnlich aus, aber im Detail fehlen viele Kleinigkeiten.“ Das ist die richtige Beobachtung: eine Karte, die sich beim Zeigen nicht rührt, wirkt kaputt, und zwar bevor jemand sagen kann, warum.
Die Beitragskarte reagiert jetzt, und zwar wie im Original nachgemessen: die Karte hebt sich um 8 px, bekommt einen weichen Schatten, das Bild zieht auf 1,06 auf, und über dem Bild blendet ein Hinweis ein. Dauer und Kurven sind aus dem Original übernommen (0,28 s für die Karte, 0,5 s für das Bild, 0,3 s für den Hinweis).
Drei Entscheidungen dabei:
- Die Bewegung steht vollständig hinter der Bewegungsschranke. Wer im Betriebssystem weniger Bewegung eingestellt hat, bekommt keine sanftere Fahrt, sondern keine – aber den Hinweis über dem Bild trotzdem, denn der ist Information und keine Verzierung. Ein Test prüft genau diese Trennung: kein
translateYaußerhalb der Schranke. - Auch mit der Tastatur. Alles hängt zusätzlich an
:focus-within, sonst wäre der Effekt eine Belohnung für Mausbenutzer. - Der Hinweis ist für Vorlesesoftware ausgeblendet. Er wiederholt nur, wohin der Titel darunter ohnehin führt; zweimal dasselbe Ziel anzusagen hilft niemandem.
Der Hero war das falsche Bild. Im Original ist er kein Foto, sondern Marineblau mit einer Binärtextur bei 10 Prozent und einem roten Schein oben rechts. Nachgebaut mit den Mitteln, die der Designer hat: dasselbe Bild als Textur, die Überlagerung auf 93 Prozent, und ein Verlauf schräg darüber für den Schein. Die Augenbraue ist im Original eine Pille mit Rahmen, kein nackter Text – auch das ließ sich ohne neue Funktion bauen.
Und die Effekte, die es längst gibt, waren schlicht nicht benutzt. layout.effects kennt Anheben, Zoom, Schatten und Einblenden seit Dokumentschema v3; im Nachbau standen sie auf „nichts“. Jetzt heben sich Leistungskarten und Lösungskarten beim Zeigen. Das ist keine Änderung am CMS, sondern die Antwort auf die Frage, warum es „im Detail“ fehlte: ein Werkzeug, das man nicht in die Hand nimmt, ist so gut wie nicht vorhanden.
Nebenbei entfernt: der Leuchteffekt (fx-hover-glow) trug noch das Grün der Vorlage statt der Akzentfarbe der Website.
Nachgetragen am 13.09.2026: Umut meinte den Kopf beim Öffnen eines Beitrags, nicht den der Startseite. Im Original füllt dort das Projektbild den ganzen Kopf (75 vh), darüber liegt ein Verlauf von fast durchsichtig oben nach fast deckend unten, und darin stehen ein Badge in der Akzentfarbe, der Titel und eine 60 × 4 Pixel kurze Linie. Nachgebaut mit den vorhandenen Mitteln – Hintergrundbild, Verlauf, Flexausrichtung –, der Text mittig gesetzt. Dabei fiel auf, dass die Zeile mit Datum, Autor und Kategorie oberhalb davon stand: bei einem Beitrag, der seinen Titel im Bild trägt, ist sie doppelt. Sie ist jetzt abschaltbar (postMetaHeader), Voreinstellung bleibt an.
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.