Every installation now reports address and version to sites.kant-it.de every 15 minutes and picks up update orders there (ADR 123). Reports and answers are HMAC-signed with a per-installation key sent on first contact; an order can only install a published release with checksum. The next request after the file swap runs the migrations, so a remote update finishes without anyone in the admin. The platform itself lives in zentrale/ (plain PHP + SQLite, not part of the release archive): overview with outdated, silent and unassigned installations, single and bulk updates, per-site detail and history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 KiB
Architekturentscheidungen
Stand: 14.10.2026. Arbeitstitel KantSites; Produktname offen.
ADR 123 – Zentrale auf sites.kant-it.de: Meldung, Übersicht, Update aus der Ferne (14.10.2026)
Anlass: Umut: „Alle KantSites melden sich bei meiner Hauptplattform an … ich kann sehen, welche Domains welche Versionen nutzen, und kann von remote Aktualisierung anstoßen.“ Entschieden: sites.kant-it.de, ohne Lizenzschlüssel, Updates einzeln und für alle.
Meldung (CMS). Neue Aufgabe zentrale (App\Ops\Tasks\ZentraleTask, alle 15 Minuten) ruft App\Ops\Zentrale::melden() auf: POST https://sites.kant-it.de/api/melden mit Kennung, Adresse (SITE_ORIGIN), Fassung, PHP-Version, Datenbankart, Git/Archiv, Wartungszustand und Updatestand. Keine Inhalte, keine Besucherdaten, keine Zugangsdaten. Kennung und Schlüssel (32 bzw. 64 Hexzeichen, zufällig) liegen in var/zentrale.json und bleiben bei Updates erhalten. Abschalten mit ZENTRALE_URL=aus; Installationen mit lokalem SITE_ORIGIN (localhost, 127.0.0.1, *.test, *.localhost, *.invalid) melden nie.
Echtheit ohne Lizenzschlüssel. Beim ersten Kontakt schickt die Installation ihren Schlüssel über HTTPS mit (Vertrauen beim ersten Kontakt). Danach ist jede Meldung mit HMAC-SHA256 über den Rohtext unterschrieben (X-KantSites-Signatur), jede Antwort der Zentrale ebenso; die Installation verwirft Antworten ohne gültige Unterschrift. Eine bekannte Kennung mit fremdem Schlüssel weist die Zentrale ab (403). Kennt die Zentrale eine Kennung nicht mehr (409), meldet sich die Installation einmal mit Schlüssel neu an. Ein Auftrag kann nur „Update auf eine höhere Fassung der Form 1.2.3“ sein und läuft über ArchiveUpdate mit Prüfsumme der Releaseseite – mehr kann auch eine kompromittierte Zentrale nicht auslösen.
Pull statt Push. Die Zentrale verbindet sich nie mit einer Website. Sie merkt sich den Auftrag; die Website holt ihn mit ihrer nächsten Meldung ab. Keine Installation braucht dafür eine von außen erreichbare Steuerschnittstelle, und es geht auch hinter Firewalls.
Update aus der Ferne. ZentraleTask führt ArchiveUpdate::run() aus (Sicherung, Archiv, Prüfsumme, Dateitausch, Wartung an) und markiert den offenen zweiten Schritt mit fern (ArchiveUpdate::markiereFern()). Den zweiten Schritt erledigt die nächste Anfrage mit dem neuen Code: RemoteUpdateFinishSubscriber (kernel.request, Priorität 48, also vor der Wartungsseite) nimmt die Updatesperre, führt finish() mit der Funktionsprüfung aus und gibt die Website frei oder schaltet zurück. Damit das nicht auf den nächsten Besucher wartet, ruft die Aufgabe die eigene Startseite einmal auf (bis 300 s). Das Ergebnis geht mit der nächsten Meldung an die Zentrale und wird nach der Quittung aus var/zentrale.json gelöscht. Git-Arbeitskopien lehnen Aufträge ab. Die Funktionsprüfung nach den Migrationen ist dafür aus SystemController in App\Ops\Funktionspruefung gewandert; die Verwaltung nutzt denselben Dienst.
Zentrale. Eigener Ordner zentrale/ im Repository, nicht Teil des Releasearchivs (scripts/build-release.php nimmt nur die aufgezählten Einträge). Reines PHP ab 8.1 mit SQLite (var/zentrale.db), ohne Framework und Composer, damit sie per FTP auf jedes Hosting passt. Anmeldung mit password_hash, Sitzungscookie __Host- mit SameSite=Strict, CSRF-Token auf jedem Formular, fünf Fehlversuche je IP in 15 Minuten, strenge CSP ohne Skripte, noindex. Das erste Konto legt an, wer den einrichtungscode aus config.php kennt. Die Übersicht zeigt Domain, Kunde, Fassung, letzte Meldung und Auftragsstand, zählt aktuelle, veraltete, verstummte (über 48 h) und nicht zugeordnete Installationen und bietet „Auf x.y.z“ je Zeile und „Alle aktualisieren“ mit Bestätigung (ohne verstummte, wartende und Git-Installationen). Die neueste Fassung kommt von derselben Releaseseite wie bei den Installationen, 30 Minuten zwischengespeichert. Fremde Nutzung erkennt man an „Unbekannt“ (kein Kunde zugeordnet) und am Hinweis „vorher …“ bei einer Kennung, die sich unter neuer Domain meldet.
Grenzen. Wer den Code verändert und die Meldung ausbaut, bleibt unsichtbar; ohne Lizenzschlüssel ist das kein Schutz, nur eine Übersicht. Installationen vor 1.36.0 erscheinen erst nach einem Update von Hand. Erreicht eine Website sich selbst nicht, steht die Wartungsseite bis zum nächsten Besuch oder Cron-Aufruf. Die Zentrale speichert die IP-Adresse der letzten Meldung; das gehört in die Datenschutzerklärung beziehungsweise Auftragsverarbeitung mit den Kunden.
Folgen. Keine Migration im CMS: var/zentrale.json entsteht beim ersten Lauf, die Aufgabe trägt sich über ihr Etikett selbst in scheduled_task ein. Das Schema der Zentrale migriert sich über PRAGMA user_version (zentrale/app/Datenbank.php). Protokollprüfung ohne Netz: php scripts/zentrale-check.php; der Ablauf mit Dateitausch und Abschluss steht in tests/ArchiveUpdateTest.php.
ADR 122 – E-Mail-Versand als eigene Seite, Probe nicht mehr unter /.well-known (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS. Mit 1.34.0 zeigte die Testmail, dass sendmail -bs dort abbricht und native://default (PHP mail()) geht, solange der Absender zu einer Domain des Vertrags gehört. Umut: „Können wir die Einstellungsmethoden ins Backend verlagern? Dass man dort alle Methoden probiert und auch SMTP setzt?“ – und „bau es in ein Untermenü ein, nicht System vollstopfen“. Gleichzeitig scheiterte „HTTPS erzwingen“ mit „antwortet mit 403“, obwohl HTTPS lief.
E-Mail-Versand. Eigene Seite /admin/system/e-mail (MailController), im Menü als Unterpunkt von „System“; die Karte auf der Systemseite ist weg. Wege: SMTP mit Postfach (Server, Port, Benutzer, Passwort), PHP mail(), sendmail -t, sendmail -bs. „Diesen Weg testen“ schickt eine Testmail über den gewählten Weg, ohne ihn zu speichern; „Alle Wege testen“ probiert alle Wege des Servers und – wenn eingetragen – SMTP nacheinander; „Diesen Weg speichern“ schreibt MAILER_DSN über Environment::write in .env (nur wenn sie dort steht, nicht aus der Server-Umgebung). MailCheck::smtpDsn baut die DSN mit URL-kodiertem Benutzer und Passwort, Port 465 wird smtps. Das Passwort steht nie im Formular: leer gelassen gilt das gespeicherte, solange Server und Benutzer gleich bleiben. Derselbe Weg wie eingestellt nutzt den Transport des Frameworks, ein anderer wird mit Transport::fromDsn gebaut. Hinweise neu: fremder Absender bei Server-Versand („Sender address is not allowed“) und bei sendmail-Fehlern zuerst mail().
Probe. OriginCheck::PATH ist jetzt /kantsites-origin statt /.well-known/kantsites-origin. IONOS beantwortet alles unter /.well-known/ selbst mit 403 (nachgemessen mit und ohne www, http und https); die Anfrage erreicht die Installation nie, deshalb konnten „Adresse speichern“ und „HTTPS erzwingen“ dort nicht gelingen. Die Ausnahme für .well-known in public/.htaccess bleibt für Zertifikatsanbieter.
Folgen. Die Tests setzen die angezeigte Einstellung über when@test in config/services.yaml (kant.mail_dsn, kant.mail_log); in config/packages/test/ wurden die Parameter von services.yaml überschrieben, so dass die Tests von 1.34.0 in das lokale var/log/mail.log schrieben (nicht Teil eines Releases). Keine Migration.
ADR 121 – E-Mail-Versand mit Protokoll und Testmail (12.10.2026)
Anlass: Auf huefner-gartenbau.de (IONOS) standen zwei Anfragen als „Nicht versendet“ in der Verwaltung, und nirgends stand, warum: FormController fing die TransportException und verwarf sie. Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt, und wo Logs angezeigt werden, woran es liegt.“
Entscheidung. Jeder Versand geht durch App\Ops\MailCheck: Formular und Testmail. Die Klasse versendet über den Transport aus MAILER_DSN, wirft nie und schreibt je Versuch eine JSON-Zeile nach var/log/mail.log (per FTP lesbar, ab 256 KB nach .1 rotiert): Zeit, Anlass, Empfänger, Absender, Ergebnis, Meldung des Servers, ein Hinweis auf Deutsch für die häufigen Fälle (Anmeldung abgelehnt, Server nicht erreichbar, TLS, sendmail nicht nutzbar, Absender abgelehnt, ungültige DSN, null://) und die Antworten des Servers. Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers (< ), denn die Zeilen des Clients enthalten bei AUTH LOGIN Benutzer und Passwort in Base64. Die Besucheradresse (Antwortadresse) steht nicht im Protokoll.
Systemseite. Neue Karte „E-Mail-Versand“: Versandweg ohne Passwort (Art, Server, Benutzer, Quelle der Einstellung), Feld für eine beliebige Empfängeradresse und einen Absender (vorbelegt mit dem SMTP-Benutzer, wenn er eine Adresse ist), danach Ergebnis, Hinweis und Serverantworten, darunter die letzten zehn Versuche. Nur Administratoren, mit CSRF-Token.
Folgen. Der Transport wird träge gebaut (lazy), damit eine ungültige MAILER_DSN den Versand scheitern lässt und im Protokoll steht, statt die Systemseite selbst. Die Einstellung bleibt in .env; ein Feld zum Ändern der DSN in der Verwaltung gibt es bewusst noch nicht (Passwort im Formular). Tests schreiben ihr Protokoll in den Cache (kant.mail_log, überschreibbar mit MAIL_LOG). Keine Migration; der Grund steht im Protokoll, nicht an der Einsendung.
ADR 120 – HTTPS erzwingen aus der Verwaltung (12.10.2026)
Anlass: Umut: „HTTPS wird noch nicht erzwungen, wie machen wir das allgemein?“ Bisher hieß das: per FTP die drei auskommentierten Zeilen in public/.htaccess einschalten. Die Anwendung selbst erzwingt bewusst nichts (ADR zur Adresse: hinter einem Proxy sieht PHP oft nur http, ohne vertrauenswürdige Proxys wäre das eine Schleife).
Zwei Fehler, beim Bau gefunden und auf einem echten Apache (XAMPP 2.4) nachgestellt.
- Seit 1.28.1 stand der HTTPS-Block hinter
RewriteRule ^index\.php$ - [L]und der Regel fürindex.php. Nach dem Umschreiben aufindex.phpbeginnt die Runde neu und endet an der ersten Regel, die Umleitung kam für Seiten nie an die Reihe: Mit eingeschalteten Zeilen antworteten/und/leistungenmit 200, nur echte Dateien (/x.css) mit 301. Der Block steht jetzt direkt nachRewriteBase; nachgemessen: alle Pfade 301, mitX-Forwarded-Proto: https200 (keine Schleife hinter einem Proxy). FilesMatch "^\."sperrte auch/.well-known/…: Apache prüft den ersten nicht vorhandenen Pfadteil gegen die Regel. Die Probe/.well-known/kantsites-originantwortete deshalb unter Apache mit 403 – auch die Prüfung bei „Adresse speichern“ konnte dort nie gelingen. Ausgenommen ist jetzt genau.well-known(^\.(?!well-known$));.env,.htaccess,.htpasswdbleiben 403 (nachgemessen).
Entscheidung. App\Ops\HttpsRedirect schaltet die vorhandenen Zeilen ein und aus, unter System („HTTPS“) und als cms:https [an|aus|hsts-an|hsts-aus]:
- Einschalten nur mit
SITE_ORIGINaufhttps://und nur, wenn die Installation dort vorher mit gültigem Zertifikat ankommt (OriginCheck, dieselbe Kennungsprobe wie beim Adresswechsel). - Danach dieselbe Probe noch einmal. Kommt sie nicht mehr an, ist es eine Schleife (der Server meldet HTTPS nicht und setzt keinen Proxy-Kopf): Die Datei wird sofort zurückgeschrieben.
- Dann
http://<Adresse>ohne Weiterleitungen folgen: 301/308 auf genau die https-Adresse ist der Erfolg. Antwortet http anders, liest der Server die Regel nicht (mod_rewrite, AllowOverride) – die Zeilen schaden nicht, die Meldung verweist auf die Weiterleitung des Hosters. Erreicht der Server sich selbst nicht über http, sagt die Meldung das statt zu raten. - HSTS ist ein eigener zweiter Schalter, nur bei eingeschalteter Umleitung und funktionierendem HTTPS, mit Warnung: Ein Browser hält den Kopf ein Jahr. „Umleitung ausschalten“ nimmt HSTS mit.
- Darf PHP die Datei nicht schreiben oder fehlen die Zeilen, zeigt System, was von Hand zu tun ist.
Updates. Die Zusammenführung, die eingeschaltete Zeilen in die neue .htaccess mitnimmt, steht jetzt in HttpsRedirect::keepEnabled() und gilt für beide Wege: das Archiv-Update wie bisher, und neu der Git-Updater, der die Datei vorher mit checkout --force überschrieb. Eine geänderte public/.htaccess zählt im Git-Weg nicht mehr als lokale Änderung, die das Update verweigert. Weil die Zeilen nach Inhalt und nicht nach Position gefunden werden, landet eine von Hand eingeschaltete Umleitung beim Update an der neuen, richtigen Stelle.
Folgen. Keine Migration. Installationen, die die Zeilen schon von Hand eingeschaltet haben, leiteten bisher nur Dateien um; mit dem Update auf 1.33.0 leiten sie alles um.
ADR 119 – Das Menü schließt nach einer Wahl (12.10.2026)
Anlass: Umut: „Wenn man Links zu Ankern, also #kontakt, im Menü klickt, dann schließt sich das Menü nicht beim Mobilmenü. Also immer schließen, wenn man ausgewählt hat, egal was.“
Ursache. Das aufklappbare Menü ist ein details (ADR 117: öffnet ohne Skript). Ein Sprungziel auf derselben Seite lädt nicht neu, also bleibt details offen und liegt über dem Ziel. CSS kann ein details bei einem Linkklick nicht schließen.
Entscheidung. scrolled.js, das kleine Skript des Kopfbereichs, schließt das Menü beim Klick auf einen Link darin (initMenus). Es kommt nur dorthin, wo es gebraucht wird: wenn ein aufklappbares Menü (Burger oder waagerecht mit Zusammenklappen) einen Link auf ein Sprungziel dieser Seite trägt (#… oder der eigene Pfad mit #…). Ein Link auf eine andere Seite braucht nichts: Die Seite lädt neu, und weil öffentliche Seiten no-store senden, stellt „Zurück“ kein offenes Menü aus dem Seiten-Cache her (im Browser geprüft). So bleibt jede Seite ohne solche Sprünge bei script-src 'none', und auf Seiten, die scrolled.js ohnehin laden (Nach-oben-Knopf, Kopf mit Scrollfarben), kommt kein Skript dazu.
Geprüft: Im Browser auf Hüfner (iPhone): „Kontakt“, „Angebot anfragen“ und „Leistungen“ schließen das Menü und springen; „Alpakas“ und „Zurück“ zeigen es geschlossen. Die bestehenden Tests, die script-src 'none' auf Seiten mit aufklappbarem Menü ohne Sprungziele verlangen, bleiben unverändert grün.
ADR 118 – Inhaltspakete: Export und Import ohne die Konten (12.10.2026)
Anlass: Umut: „Können wir sowas wie einen Importer einbauen, weil sich nach jeder Anpassung sonst das Passwort ändert? Wo man auswählen muss, ob Passwort/Benutzer überschrieben werden sollen. Gerne auch als Plugin.“ Bisher wurde eine lokal gebaute Website als ganze SQLite-Datei hochgeladen – samt Konten (neues Passwort nach jedem Neuaufbau) und ohne das, was nur live entstanden war (Anfragen, Statistik).
Entscheidung: im Kern, nicht als Plugin. Plugins führen bewusst keinen eigenen Servercode aus (ADR zum Pluginsystem; serverseitiger Code nur mit Isolation). Ein Import schreibt Datenbank und Medienordner – das gehört in den Kern, unter System, nur für Administratoren.
Paket. Zip mit paket.json (Art, Format 1, Fassung, Website, Konten ja/nein, Zahlen, SHA-256 von inhalt.json), inhalt.json (Zeilen je Tabelle) und medien/ (Bildvarianten, PDFs, erzeugte Seitensymbole). JSON statt SQLite-Datei: Ein Paket geht von SQLite nach MySQL und zurück, und jede Zeile wird vor dem Schreiben geprüft. Inhalt: page, revision, partial, menu, media, document, form, redirect, term, page_term, page_translation und die Website (Name, Sprache, Gestaltung, Einstellungen). Nicht darin: Anfragen, Statistik, Aufgaben, Plugin-Stand, Migrationsliste und die Zugangsdaten zu Diensten (turnstile, turnstileSiteKey, turnstileSecret). Konten nur mit „Konten mitnehmen“ beim Export.
Import (App\Ops\SitePackage::import). Prüfen vor Schreiben: nur bekannte Einträge im Zip (keine Pfade nach oben, keine anderen Dateitypen, Größengrenzen gegen Zip-Bomben), Prüfsumme von inhalt.json, nur bekannte Tabellen und Spaltennamen, jedes Seiten- und Bereichsdokument durch DocumentValidator, Paket nicht aus einer neueren Fassung. Dann Sicherung (vor Import), Wartungszustand, Inhalte in einer Transaktion ersetzen (nur Spalten, die es hier gibt – ein älteres Paket passt so in ein neueres Schema), Website-Einstellungen übernehmen außer den Dienst-Zugangsdaten, Anfragen über die Formularadresse dem neuen Formular zuordnen, Medienordner ersetzen. Konten nur mit eigenem Häkchen („Konten und Passwörter aus dem Paket übernehmen“); ohne bleiben Benutzer und Passwörter der Website. Ein Häkchen bei einem Paket ohne Konten bricht ab, ohne etwas zu ändern.
Oberfläche. System → „Inhalte übertragen“: Export als Download (Datei wird nach dem Senden gelöscht), Import per Upload oder aus var/pakete/ (für Pakete über der Upload-Grenze des Hostings, die angezeigt wird), Bestätigungshäkchen, Ergebnisseite mit den Schritten und einem Hinweis, falls Schriften der neuen Gestaltung auf dem Server fehlen. Konsole: cms:paket:export [--mit-benutzern] [--ziel=…], cms:paket:import <datei> [--mit-benutzern] [--ja].
Folgen. Das Zurückspielen einer Sicherung bleibt Konsolensache; der Import ist der Weg für Inhalte aus einer anderen Installation, mit eigener Sicherung davor. Reset-Skripte können ein Paket schreiben (zuerst reset-huefner.sh → var/huefner-paket.zip). Keine Migration.
ADR 117 – Menü schließt beim Klick daneben, Netzwerk-Symbole, schlankere Symbole (12.10.2026)
Anlass: Umut: „Bei Mobilmenü, wenn es aufgeklappt ist, schließt es nicht, wenn man irgendwo anders hinklickt.“ Und für Hüfner: Facebook, Instagram und „Angebot anfragen“ als Menü-Buttons statt eigener Knöpfe im Kopf.
Entscheidungen.
- Klick daneben schließt – ohne Skript. Das Menü bleibt ein
details(es soll auf jeder Seite ohne Skript aufgehen). Solange es offen ist, legtsummary::beforeeine unsichtbare, feste Fläche über die Seite (z-index:29); sie gehört zum Knopf, ein Klick darauf schließt also. Die Menüfläche liegt darüber (z-index:30; außerhalb des Kopfbereichs per:where()mit niedriger Spezifität, damit die Platzierung im Kopf gewinnt). Der erste Klick daneben schließt nur und löst nichts darunter aus – wie bei jedem aufgeklappten Menü. Escape schließt weiterhin nicht (das bräuchte ein Skript). Grenze: Hat ein Vorfahr des Menüstransform/filter, deckt die feste Fläche nur diesen Vorfahren ab. - Facebook, Instagram, WhatsApp im Symbolsatz. Dieselben Strichformen wie
social-icon.html.twig, als Pfad inicon.html.twig,icons.js(Reiter „Soziale Netzwerke“) undDocumentValidator::ICONS. Damit sind sie an Menü-Buttons, Buttons, Symbolkarten und Symbolen wählbar. Keine Schemaänderung; ein Dokument mit diesen Symbolen braucht aber einen Kern ab 1.31.0. - Strichattribute ins Stylesheet. Jedes Symbol trug
fill,stroke,stroke-width,stroke-linecapundstroke-linejoinals Attribute, rund 105 Zeichen, bei etwa 40 Symbolen je Seite gut 4 KB HTML. Jetzt steht das einmal alssvg.block-icon{…}insite.cssundadmin.css;width/heightbleiben am Element, damit nichts ohne Stylesheet riesig wird. Anlass war die Hüfner-Startseite, die mit den Menü-Buttons das HTML-Budget von 150 KB riss.
Geprüft: Eigene Browserprüfung auf iPad (quer) und iPhone: Menü öffnet, Menülinks liegen über der Fläche, Klick daneben schließt, kein waagerechtes Scrollen. test:menu-buttons (Menü-Buttons, axe), test:editor (Verwaltung mit Symbolen, axe), npm test (Symbolsatz in Twig, JS und Validator gleich). test:responsive-navigation scheitert im Editorteil am Klick auf den Strukturbaum – ebenso ohne diese Änderungen (Gegenprobe mit zurückgesetzten Dateien), also vorbestehend.
ADR 116 – Härtung nach dem Sicherheitscheck (12.10.2026)
Anlass: Umut: „Kannst du mal einen Sicherheitscheck für KantSites machen? Das Ding muss bombensicher sein.“ Fünf getrennte Prüfungen (Anmeldung/Rollen, öffentliche Eingaben, XSS/Ausgabe, Uploads/Dateien, Installer/Updater/Betrieb) plus ein Blick von außen auf die Live-Seiten. Keine ausnutzbare Lücke ohne Anmeldung bei korrekt eingerichteter Installation; behoben wird, was übrig blieb.
Entscheidungen.
- Installationsschlüssel. Der Installer war bis zum ersten Konto für jeden offen – wer eine frisch entpackte Installation zuerst aufrief, konnte sie übernehmen (und vorher über den MySQL-Host fremde Rechner abtasten). Jetzt öffnet sich kein Schritt ohne einen 128-Bit-Schlüssel in
var/install.key.install.phplegt ihn an und zeigt ihn nur auf seiner Abschlussseite mit fertigem Verweis (/install/schluessel?schluessel=…); wer per FTP entpackt, liest die Datei. Im Browser bleibt ein Cookiekant_installmit einem HMAC über den Schlüssel (HttpOnly, SameSite=Strict, Pfad/install), nie der Schlüssel selbst. Mit dem ersten Konto wird die Datei gelöscht; das Anlegen des ersten Kontos läuft in einer Transaktion. Restrisiko: Werinstall.phpvor dem Betreiber aufruft, sieht den Schlüssel – das Fenster ist die Zeit zwischen Hochladen und Aufrufen. - Sperrdateien. Zeigte die Domain auf den Projektordner statt auf
public/, waren.env, Datenbank, Sicherungen und Protokolle abrufbar; die Sperren standen nur inpublic/.htaccess. Jetzt sperren.htaccess-Dateienbin/,config/,migrations/,plugins/,src/,templates/,vendor/(Buildskript) undvar/(legt die Website selbst an,App\Ops\Protection), dazu im Projektstamm gezielt Dotfiles,env|db|sqlite|lock|md|yaml|yml|dist|log|keyundcomposer.json,VERSION,cli-config.php,migrations.php. Bewusst kein pauschales „alles sperren“ im Projektstamm: Apache wendet die Datei auch aufpublic/an, ein Update hätte jede Seite mit 403 lahmlegen können. Eine eigene.htaccessdes Betreibers im Projektstamm bleibt unberührt; fehlende Dateien trägt die Website beim ersten Aufruf nach (auch nach einem Update von einer Fassung, deren Updater sie nicht kannte). Vorlagen:config/schutz/. - Sektionen nach ihrem eigenen Schema-Stand. Eine verknüpfte Sektion wurde mit den Ausgabeflags der Seite gerendert. Eine Sektion mit Schema unter v12 (deren Text nicht gefiltert wird) gab so in einer neueren Seite ungefiltertes HTML aus – ein Publisher konnte
<meta refresh>oder fremde Links setzen, was sonst nur der Admin darf. Jetzt rechnet die Vorlage mitsection.schemaVersion; neue Sektionen beginnen im aktuellen Schema. - Redakteure ändern nichts Veröffentlichtes. Adresse verschieben, Papierkorb (jeweils samt Unterseiten), Kopf/Fuß wählen und Beitragsdaten (robots, SEO-Titel, Datum …) wirkten sofort öffentlich und brauchten nur ROLE_EDITOR. Jetzt verlangen sie ROLE_PUBLISHER, sobald die Seite (bzw. eine Unterseite) veröffentlicht ist; Entwürfe bleiben frei.
- Geplante Veröffentlichung mit fester Fassung. Zum Termin ging der neueste Entwurf online, auch einer, den ein Redakteur nach dem Planen gespeichert hatte. Jetzt speichert
schedule()die damalige Fassung inpage.publish_revision_id(Migration20261012000100, eine Spalte);publishDue()veröffentlicht genau sie. Planungen ohne Fassung (aus älteren Versionen) veröffentlichen weiterhin den Entwurf. - Formulare. Das Zeitsiegel ist an das Formular und den Zweck gebunden (
FormRepository::stamp). Die Grenze je Absender zählt bei IPv6 das /64-Netz. Neu: höchstens 30 gespeicherte Anfragen je Formular und 120 insgesamt je Stunde; darüber wird nichts gespeichert und nichts verschickt – sichtbar mit Hinweis aufs Telefon statt still, damit kein echter Besucher glaubt, seine Anfrage sei angekommen. - Kleinere Härtungen. Einwilligungs-Rücksprung ohne Steuerzeichen und Backslash (Tabulator machte aus
/<Tab>/fremdeine offene Weiterleitung). Update-Ziel nur in der Form1.2.3bzw.v1.2.3,git checkoutmit--. Eigenes CSS: keine Geschwister des Blocks selbst (& ~,& +), Anführungszeichen paarweise, keine Kommentarzeichen in Werten; bereits gespeichertes CSS, das das verletzt, entfällt beim Ausgeben still, statt die Seite zu brechen.
Nicht umgesetzt, mit Grund. Signierte Releases (Umut: später); dann gehört ein Offline-Schlüssel dazu. Antwortzeit beim Login für unbekannte Benutzer, Trusted Proxies, Turnstile-Hostname, %PDF- ab Byte 0, archivierte PDFs, Statistik-Obergrenzen, MySQL-Passwort in der Prozessliste: niedrig, offen in STATUS.
Folgen. Neuinstallationen brauchen den Schlüssel; die Doku sagt, wo er steht. Bestehende Installationen merken nur die Sperrdateien (keine Wirkung bei korrekter Einrichtung) und die engeren Rollen. Migration läuft mit dem Updater. Ausgeliefert mit 1.30.0.
ADR 115 – Einwilligung ohne Neuladen (12.10.2026)
Anlass: Umut auf der Hüfner-Seite: „dieses Einwilligen, da lädt die Seite komplett neu. Bekommen wir das auch ohne hin?“ Hinweis und Knopf am eingebetteten Inhalt waren reine Formulare (ADR zur Einwilligung: kein Skript), der Server setzte das Cookie und leitete zurück.
Entscheidung. Das Formular bleibt der Weg, ein kleines Skript public/assets/consent.js verbessert ihn. Es schickt dasselbe Formular im Hintergrund an /einwilligung mit der Kopfzeile X-Kant-Consent: 1; der Server antwortet dann mit 204 und dem Cookie statt mit der Weiterleitung (Herkunftsprüfung unverändert). Wurde etwas erlaubt, holt das Skript die Seite noch einmal und übernimmt daraus nur die eingebetteten Inhalte und die Zeile „Rechtliches“ (dort steht jetzt „Einwilligung ändern“); dann verschwindet der Hinweis. Der Fokus geht an den eingesetzten Rahmen bzw. an #main. Schlägt etwas fehl, sendet das Skript das Formular normal ab. Die Adresse eines Inhalts steht weiterhin erst nach der Einwilligung im HTML – kein data-src im Platzhalter.
Was sich dafür ändert. (1) Die Sicherheitsrichtlinie nennt die Hosts aller eingebetteten Inhalte der Seite in frame-src, auch solange die Einwilligung fehlt: eine Richtlinie steht beim Laden fest, ein nachträglich eingesetzter Rahmen bräuchte sonst ein Neuladen. Die Erlaubnis lädt nichts. (2) Seiten mit offener Frage (Hinweis offen oder ein Inhalt wartet) senden script-src 'self' und laden consent.js; nach der Entscheidung ohne wartende Inhalte wieder script-src 'none'. (3) Das Skriptbudget von test:browser (3 je Seite) zählt consent.js nicht mit, weil es nur bis zur Entscheidung da ist.
Folgen. Ohne JavaScript bleibt alles wie bisher. Andere Vorlagen als PageController (falls sie Einbettungen zeigen) laden das Skript nicht und bleiben beim Neuladen. Ausgeliefert mit 1.29.0.
ADR 114 – Die Umschreibregel fängt index.php selbst ab (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS (PHP 8.4.26) antwortete auf /, /install und /assets/ mit einem weißen 500er von Apache. Die Notfallschicht (ADR 113) griff nicht, weil PHP gar nicht erreicht wurde. /index.php/install lieferte dagegen 200, auch mit der .htaccess – die Datei wurde also gelesen, der Fehler lag in der Umschreibregel. Vermutete Ursache: RewriteCond %{REQUEST_FILENAME} !-f erkennt dort die bereits umgeschriebene index.php nicht als Datei, die Regel greift erneut, Apache bricht nach zehn internen Umleitungen ab.
Entscheidung. public/.htaccess setzt RewriteBase / und beendet die Umschreibung für index.php selbst (RewriteRule ^index\.php$ - [L]), wie die .htaccess von WordPress. Auf dem Hosting von Umut mit beiden Zeilen bestätigt; welche der beiden allein genügt hätte, ist nicht geprüft. CmsTest sichert die Zeilen ab.
Folgen. RewriteBase / setzt voraus, dass die Domain auf public/ zeigt – das war schon Bedingung, eine Installation in einem Unterordner einer Domain ist nicht vorgesehen. Bestehende Installationen bekommen die Zeilen mit dem nächsten Archivupdate, weil der Updater die .htaccess des Releases übernimmt (nur eingeschaltete HTTPS-/HSTS-Zeilen werden erhalten). Options -Indexes -MultiViews verträgt IONOS: auf huefner-gartenbau.de wieder eingeschaltet, die Seite lädt weiter. Ausgeliefert mit 1.28.1.
ADR 113 – Kein leerer 500er mehr, Update mit einem Klick nach reiseSoft (09.10.2026)
Anlass: Auf kantarci-reisen.de wurde die Datenbank per FTP ersetzt, ohne vorher den Code zu aktualisieren – danach antwortete jede Adresse mit einer leeren 500-Seite, und nirgends stand, warum. Umut: „Ich meine, dass es nicht Fehler 500 einfach so gibt.“ Und zum Updater: suchen, dann einspielen, dann migrieren soll ein Vorgang sein, den man beim Aufruf der Systemseite sieht – „mach es wie bei reiseSoft“.
Notfallschicht. src/Ops/Notfall.php hängt an nichts außer PHP und wird in public/index.php vor dem Bootstrap geladen. Sie fängt Ausnahmen, die bis zum Front-Controller durchschlagen, und schwere Fehler (Shutdown-Handler: Parse-, Compile-, Speicherfehler), schreibt eine Zeile mit Zeit, Adresse, Fehler und bis zu drei Ursachen nach var/log/notfall.log (ab 1 MB nach .1 rotiert) und gibt eine schlichte Wartungsseite mit 503 und Retry-After: 300 aus. Damit ist die Datei per FTP lesbar, auch wenn Symfony gar nicht startet – das ist auf geteiltem Hosting oft der einzige Zugang. 503 statt 500, damit Suchmaschinen „gleich wieder da“ verstehen. Einzelheiten stehen nur im Protokoll, nie auf der Seite.
Fehler innerhalb des Frameworks. ErrorLogSubscriber hört auf kernel.exception mit Priorität −64: nach der Sicherheitsschicht (sonst wird „Zugriff verweigert“ zur Wartungsseite statt zur Anmeldung – so schlug CmsTest an), vor der Fehlerseite des Frameworks (−128). Er übergeht HTTP-Fehler unter 500 sowie Anmelde-, Zugriffs- und Abmeldeausnahmen, protokolliert den Rest und setzt außerhalb des Debugmodus dieselbe Wartungsseite. Im Debugmodus bleibt die ausführliche Seite.
Update mit einem Klick. Vorbild ist die Systemseite von reiseSoft: beim Öffnen fragt die Seite selbst nach einer neuen Fassung (GET /admin/system/update/pruefen) und zeigt Name, Datum und Hinweise des Release-Eintrags. Ein Knopf, ein Bestätigungsdialog, dann läuft alles durch: POST …/update/starten sichert, lädt und prüft das Archiv, schaltet um und endet mit reinem PHP (ADR 112); die Seite ruft danach POST …/update/abschliessen mit dem Einmalschlüssel auf – eine neue Anfrage mit dem neuen Code – und der migriert, prüft und schaltet die Wartung aus. Währenddessen fragt sie alle 1,5 s GET …/update/status ab und zeigt fünf Schritte: Daten sichern, Fassung laden und prüfen, Umschalten, Datenbank anpassen, Funktion prüfen. Der Fortschritt steht in var/update/status.json (Phase, Meldung, von/auf, bei = Schritt eines Fehlers; nach 30 Minuten ohne Änderung gilt ein Lauf als abgebrochen).
Automatisch zurück. Die Prüfung ist eine Unteranfrage auf / mit dem neuen Code; erwartet wird ein Status unter 400, wenn eine Startseite veröffentlicht ist. Scheitern Migration oder Prüfung, kommt die alte Fassung zurück: Code aus var/update/<lauf>/alt, überschriebene Dateien in public/ aus alt-public (neu hinzugekommene werden entfernt, Liste in public.json), Datenbank aus der Sicherung des Laufs (Backup::restore). Erst danach geht die Wartungsseite aus; der Status heißt zurueckgerollt, die Seite markiert den gescheiterten Schritt.
Folgen. Die Endpunkte verlangen die Administratorrolle, schreibende zusätzlich CSRF (Kopfzeile X-CSRF-Token) bzw. den Einmalschlüssel. Ohne JavaScript bleiben die bisherigen Formulare unter „Weitere Möglichkeiten“. Wie ADR 112 wirkt der neue Ablauf erst bei Updates, die von einer Fassung mit diesem Code ausgehen; die Notfallschicht wirkt sofort nach dem Einspielen. Geprüft Ende zu Ende mit einer Installation aus dem Releasearchiv: Update 1.27.1 → Testfassung in etwa 8 s mit einem Klick; eine absichtlich kaputte Testfassung wurde vollständig zurückgeschaltet (VERSION, Vorlagen, Datenbank, Wartung aus, Startseite 200).
ADR 112 – Das Archivupdate läuft in einem Zug durch (09.10.2026)
Umut nach dem Update von kantarci-reisen.de auf 1.27.0: „Update durchgelaufen, direkt danach kam 500, dann bin ich zurück und hab migrieren können.“ Und: „Können wir den Updater nicht besser machen, dass das alles automatisch abläuft, einfach wie bei WordPress?“
Befund. Lokal mit dem Releasearchiv 1.26.0 und der Reisen-Datenbank vor der Migration nachgestellt: Alle Schritte bis „Dateien sind ersetzt“ laufen sauber, dann stirbt dieselbe Anfrage mit 500. Der PHP-Prozess hat den alten Code geladen, auf der Platte liegt der neue; was danach noch nachgeladen wird (Antwort-Listener, Sitzung, Vorlagen), passt nicht mehr zum Geladenen. ADR 071 hatte das gesehen und die Migrationen deshalb in eine zweite Anfrage gelegt – aber die erste Anfrage lief nach dem Tausch trotzdem durch das ganze Framework weiter.
Entscheidung. Die Anfrage, die tauscht, gibt danach nur noch eine schlichte Seite mit reinem PHP aus (SystemController::sendAndStop: Kopfzeilen, Text, exit) – kein Framework-Code mehr. Die Seite ruft per <meta http-equiv="refresh"> sofort GET /admin/system/abschliessen?token=… auf. Das ist eine neue Anfrage mit dem neuen Code: Migrationen, Aufräumen, Wartungszustand aus, Weiterleitung zur Systemseite mit „Update auf x.y.z abgeschlossen“. Ein Klick, keine Zwischenseite zum Bedienen, kein 500er.
Sicherheit. Der Einmalschlüssel (32 Hexzeichen) steht in var/update/offen.json neben Fassung und Zeitpunkt; der Abschluss verlangt ihn und die Administratorrolle (hash_equals). Er ersetzt das CSRF-Token, das die Zwischenseite nicht mehr erzeugen kann. Neu laden findet nichts mehr offen und meldet „bereits abgeschlossen“. Der bisherige Knopf „Migrationen ausführen“ bleibt als Rückweg, falls die Weiterleitung unterdrückt wird; daneben steht ein Link.
Folge. Wirkt ab Updates, die von einer Fassung mit diesem Code ausgehen: von 1.27.0 auf 1.27.1 läuft noch der alte Weg (einmal 500, dann „Migrationen ausführen“), ab 1.27.1 der neue. Geprüft mit einer echten Installation aus dem Archiv, die auf ein lokal gebautes 1.27.1 aktualisiert (Downloader für die Probe auf lokale Dateien umgestellt): Zwischenseite mit 200, Abschluss 302, Fassung 1.27.1, Wartungszustand aus.
ADR 111 – Mehrsprachigkeit als Übersetzungsebene, nicht als zweite Seite (Dokumentschema v27, 09.10.2026)
Umut: „Ich füge weitere Sprache hinzu und man wählt aus und muss dann zu jedem Feld die 2. Sprache hinzufügen. Ich möchte nicht für die 2. Sprache die komplette Seite neu bauen.“ Danach: „/en/-Präfix und übersetzte Slugs.“ Das Datenmodell hatte seit ADR 003 eine Spalte locale an jeder Seite – also das WPML-Prinzip „eine Sprache = eine eigene Seite“. Genau das wollte Umut nicht.
Entscheidung. Eine Seite wird einmal gebaut. Je weitere Sprache trägt ihr Dokument unter translations.<sprache> nur Texte, adressiert über die stabile Block-ID: {slug?, title?, description?, seoTitle?, blocks: {<id>: {<eigenschaft>: text}}, source: {<id>.<eigenschaft>: fnv1a32}}. Welche Eigenschaften übersetzbar sind, steht einmal in Translations::FIELDS und gespiegelt in translate.js (ein Test vergleicht beide): Texte von Überschrift, Text, Button, Bild (Alt, Unterschrift), Symbolkarte, Kennzahl, Zitat, Aufruf-Band, Kontakt (Anschrift), Akkordeon, Tab, Rechtszeile, Beitragsnavigation, Symbol, Fortschritt, Externer Inhalt. Aufbau, Gestaltung, Bilder und Links gibt es nur einmal.
Warum im Dokument. So gehören Übersetzungen zu jeder Fassung: Entwurf, Veröffentlichen, Wiederherstellen und Undo im Editor gelten für alle Sprachen zugleich, ohne zweiten Revisions- und Veröffentlichungsweg. Veraltete Schreibzugriffe erkennt dieselbe Versionsprüfung. Ein gelöschter Block nimmt beim Speichern seine Übersetzungen mit (Translations::normalise). Folge: Veröffentlichen gilt für alle Sprachen einer Seite gemeinsam; eine Sprache einzeln zurückhalten geht nur, indem man ihre Texte leert.
Ausgeliefert wird eine Sprache durch Überlagerung. Translations::apply legt die Texte auf eine Kopie des Dokuments und entfernt translations; danach laufen dieselbe Prüfung (DocumentValidator, auch je Sprache beim Speichern) und dieselben Vorlagen wie für die Hauptsprache. Kein Renderer muss Sprachen kennen, und eine Übersetzung kann keine Länge, keine Auszeichnungsregel und keine H1-Regel umgehen; formatierter Text geht durch denselben RichText-Filter. Ein leeres Feld zeigt den Text der Hauptsprache. Ein dekoratives Bild bekommt auch übersetzt keinen Alternativtext.
Adressen. /en ist die Startseite der Sprache, /en/<pfad> eine übersetzte Seite. Der Pfad besteht aus den übersetzten Adressen der Seite und ihrer Vorfahren; fehlt einem Vorfahren die Übersetzung, steht seine Adresse der Hauptsprache dort. Die Tabelle page_translation (page_id, locale, slug, path) (Migration 20261009000100, neue Tabelle, kein Eingriff in page) spiegelt genau den veröffentlichten Stand: Zeilen entstehen und verschwinden beim Veröffentlichen, beim Verschieben einer Seite werden alle Pfade neu berechnet. Doppelte Adressen werden beim Speichern des Entwurfs abgelehnt; eine geänderte veröffentlichte Adresse bekommt eine 301-Weiterleitung (en/alt → en/neu). Eine Wurzelseite darf nicht wie eine aktive Sprache heißen, und eine Sprache lässt sich nicht einschalten, solange eine Seite ihre Adresse belegt.
Links. Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf deren Übersetzung – Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen. Umgesetzt als eine Ersetzung über die fertige Ausgabe (PageController::localiseLinks, nur <a href="/…">, nur Pfade aus der Zuordnung der Sprache). Links der Sprachwahl tragen hreflang und bleiben unberührt.
SEO. <html lang>, og:locale samt og:locale:alternate, Canonical auf die übersetzte Adresse, hreflang je veröffentlichter Sprache und x-default auf die Hauptsprache, die übersetzten Adressen in der Sitemap. Eine Seite ohne Übersetzung bekommt keine Alternativen. Ohne eigenen übersetzten Suchtitel nimmt die Seite den übersetzten Titel, nie den Suchtitel der Hauptsprache.
Einsprachig bleibt alles wie vorher. Sprachen stehen unter Einstellungen → Sprachen (settings.languages, nur Administratoren). Ohne weitere Sprache gibt es keinen Umschalter, keine Felder, keine /en-Route, kein hreflang. Wird eine Sprache abgewählt, verschwinden ihre Seiten von der Website; die Übersetzungen bleiben im Dokument.
Editor. Sprachumschalter DE | EN neben der Vorschaugröße. In einer weiteren Sprache zeigt die Seitenleiste die Liste aller Texte in Seitenreihenfolge, je mit Original, Feld und Zustand (fehlt / übersetzt / „Original geändert“, erkannt am FNV-1a-Vermerk), Filter, „Original übernehmen“ und „Übersetzung passt“. Dazu Adresse (mit Vorschlag aus dem übersetzten Titel) und Suchtitel. Die Vorschau zeigt die Seite in der Sprache, ein Klick springt zu den Texten des Blocks. Alles, was den Aufbau ändert (Werkzeugkasten, Struktur, Blockaktionen, Einfügepunkte, Ziehen, Spaltenbreiten, Doppelklick-Bearbeitung, Kontextmenü), ist in dieser Ansicht aus.
Baustein „Sprachwahl“ (language, Schema v27). Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist, als Kürzel oder Name; auf einer nur einsprachigen Seite nichts. Kürzel tragen den Sprachnamen für Vorlesesoftware, jeder Link lang und hreflang, die aktuelle Sprache aria-current.
Stufe 2: Bereiche, Menüs, feste Texte (09.10.2026). Kopf, Fuß und verknüpfte Sektionen tragen dieselbe Übersetzungsebene im Dokument und werden im selben Editor mit dem Umschalter übersetzt – ohne Adresse und Seitentitel. Da Bereiche keinen Entwurf haben, gilt eine gespeicherte Übersetzung sofort. Ausgeliefert werden sie wie Seiten: in einer weiteren Sprache überlagert, in der Hauptsprache ohne translations; ebenso in der Vorschau von Seite und Bereich. Menüeinträge haben keine stabile ID, deshalb trägt der Eintrag selbst seine Beschriftungen: labels: {en: "Services"} (je höchstens 80 Zeichen, bekannte Sprache, leer = Hauptsprache). Sie wandern so beim Umsortieren mit. Die Menüverwaltung zeigt je weitere Sprache ein Feld „Beschriftung Englisch“. Feste Texte der öffentlichen Vorlagen (Sprunglink, Menü-Knopf, Namen der Navigationen, Brotkrumen-Start, Rechtslinks, Einwilligung, Beitragslisten, „Nach oben“) gehen durch den Filter kant_t mit einem Wörterbuch in LanguageExtension::UI – Englisch vollständig, Französisch und Türkisch teilweise, sonst Englisch als Rückfall. Die Sprache der Anfrage steht im Anfrageattribut kant_locale.
Stufe 3: Formulare, Beiträge, Einwilligung, 404 (09.10.2026). Formulare tragen ihre Übersetzungen in den Einstellungen des Formulars (settings.translations.<sprache>: Erfolgsmeldung, Button, je Feld Beschriftung, Hilfetext, Einheit und Optionen als Zuordnung Original → Anzeige); gepflegt am Feld in der Formularverwaltung. Abgeschickt werden weiter die Optionen der Hauptsprache, damit Bedingungen, Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger unverändert bleiben. Ein verstecktes Feld language lässt die Prüfung ihre Meldungen in der Sprache geben (FormRepository::MESSAGES) und führt nach dem Absenden auf die übersetzte Seite zurück; das Schrittskript bekommt seine Texte als data-texts. Beiträge sind Seiten und werden wie Seiten übersetzt; Listen, Nachbarn und Archive zeigen übersetzte Titel und Auszüge, nicht übersetzte Beiträge in der Hauptsprache. Archive antworten unter /en/beitraege, /en/kategorie/…, /en/schlagwort/… (Präfixe bleiben deutsch). Namen von Kategorien und Schlagwörtern sowie Überschrift und Text des Einwilligungshinweises stehen je Sprache in den Einstellungen (termNames, consentTexts, Einstellungen → Sprachen). Eine 404 unter /en/… antwortet auf Englisch: die eigene 404-Seite mit ihrer Übersetzung, die mitgelieferte über das Wörterbuch.
Grenzen. Feed, Einwilligungsseite /einwilligung, Datumsformat und die Präfixe der Archivadressen bleiben in der Hauptsprache. Eigenes HTML und Plugin-Bausteine bleiben unübersetzt. Bilder sind bewusst nicht je Sprache austauschbar (Umut, 09.10.2026). Die Kennzeichnung einzelner nicht übersetzter Texte mit lang="de" fehlt.
Rückweg. Ältere Kerne lehnen Dokumente mit Schema v27 ab (wie bei jeder Schemaerhöhung); Installationen zuerst aktualisieren. Die Migration lässt sich zurücknehmen (down löscht nur page_translation); die Übersetzungen bleiben dann im Dokument, sind aber nicht mehr erreichbar.
ADR 110 – Menüeinträge als Button (07.10.2026)
Umut: „Buttons im Menü sind in KantSites aktuell nicht möglich … kannst du das im Menüeditor einbauen, dass man individuell Buttons nach dem gleichen Prinzip wie im Editor einbauen kann? Mit Icon, Farbe usw.“ Typisch ist „Angebot anfragen“ oder „Anrufen“ rechts im Kopf, auf dem Telefon griffbereit neben dem Menü-Knopf.
Datenvertrag (keine Schemaänderung der Seiten). Ein Menüeintrag trägt optional button = {id, shape?, appearance?, size?, fill?, ink?, hoverFill?, hoverInk?, iconColor?, mobile, iconMotion?, iconOnly?} im JSON-Baum des Menüs (ADR 012). Form, Darstellung und Größe stammen aus ButtonDesign::OPTIONS, Bewegung und „nur Symbol“ wie am Button-Baustein (ADR 109), das Symbol ist das vorhandene icon des Eintrags. Fehlende Werte heißen Website-Standard unter Design → Buttons. Farben sind #hex oder @palette (StyleSchema::COLOR). mobile: menu (im aufgeklappten Menü, volle Breite), bar (neben dem Menü-Knopf) oder bar-icon (dort nur als Symbol – verlangt ein Symbol). Ein Button hat keine Unterpunkte. MenuButton::validate lehnt alles andere ab, also auch CSS in Farbwerten.
Darstellung. templates/menu-button.html.twig erzeugt dieselben Klassen wie der Button-Baustein (cms-button, b-desktop-*, icon-start, icon-motion-*, icon-only), damit gelten buttons.css, Website-Standards, Hover und „Bewegung reduzieren“ unverändert. Die Menüregeln für Links (Polster, Unterstreichung, Großschrift im aufgeklappten Kopf, margin-top von .button) gelten für Menü-Buttons nicht.
Eigene Farben ohne Inline-Stil. Die CSP erlaubt keine style-Attribute. Jeder Button bekommt deshalb eine feste Kennung (m + 10 Hexzeichen, bleibt beim Speichern erhalten), und MenuRepository::buttonCss schreibt .menu-button-<id>{--kb-fill…} samt der abgeleiteten Werte der Darstellung in das Seiten-Stylesheet – auf der Website, in der Editor- und in der Bereichsvorschau.
Telefon. bar/bar-icon werden im Menübaustein ein zweites Mal als ul.menu-actions vor dem Menü-Knopf ausgegeben; sichtbar nur, solange das Menü zusammengeklappt ist (Tablet bzw. Telefon je nach Einstellung), im aufgeklappten Menü ist das Original dann ausgeblendet (menu-keep-visible) – nichts steht doppelt. Neben dem Knopf sind Buttons kompakter und brechen nicht um; Leiste und Knopf bleiben in einer Zeile. Erster Versuch nur mit bar: Ein zweizeiliger Text-Button schob den Menü-Knopf auf dem 390-px-Telefon in eine neue Zeile – daher die Variante nur mit Symbol.
Menüverwaltung. Je Eintrag „Als Button darstellen“; erst dann erscheinen Form, Darstellung, Größe, fünf Farbfelder mit Palette (wie im Editor), Bewegung, „Nur das Symbol zeigen“ und die Telefon-Anzeige, dazu eine Vorschau normal und beim Überfahren. Das Symbol wird wie im Editor im Fenster gewählt (ADR 109). Ohne JavaScript bleiben alle Felder normale Formularfelder.
Geprüft: PHPUnit vollständig, darunter testAMenuEntryRendersAsAButtonWithItsOwnColoursAndStaysBesideTheBurger, neun Ablehnungsfälle und testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId. npm test. npm run test:menu-buttons im Browser auf isolierter Pilotkopie: Verwaltung mit Fenster, Palette, Vorschau und axe; Speichern/Neuladen; Website bei 1440 und 390 px (Symbol und Menü-Knopf auf einer Linie, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite mit Rahmen, axe, kein waagerechtes Scrollen). Screenshots unter reports/menu-buttons/.
Bekannt: Ist die Spalte mit dem Menü schmal (Pilot: zwei gleich breite Spalten), bricht ein Button wie jeder Menüeintrag in die nächste Zeile; abhelfen über die Spaltenbreite oder Größe „Klein“. Menüs mit Buttons brauchen einen Kern ab 1.26.0; ältere Kerne lehnen den unbekannten Schlüssel ab.
ADR 109 – Symbole durchdacht: 98 im Satz, Auswahl im Fenster, Gestaltung unter Design (07.10.2026)
Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal […] Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“ Zu Recht: 33 Symbole waren zu wenig, die Kachelwand drückte das Panel nach unten, und Farbe oder Größe des Symbols gingen nur über eigenes CSS.
Symbolsatz. 98 Symbole in sieben Kategorien (Pfeile, Kontakt & Ort, Handel & Geld, Dateien & Medien, Personen & Sicherheit, Haus & Handwerk, Allgemein). Neu sind unter anderem Warenkorb, Euro, Lieferung (LKW), Paket, Karte, Navigation, Hochladen, Drucken, Teilen, Kamera, Schloss, Auszeichnung, Werkzeug, Hammer, Malerrolle, Baum, Blatt, Sonne, Wasser, Strom, Solar, Auto und Warnung – also auch das LKW-Symbol, das bei Hüfner fehlte. Weiter keine Markenlogos. Eine Quelle: public/assets/icons.js. npm run build:icons schreibt daraus icon.html.twig und DocumentValidator::ICONS, tests/icons.test.mjs prüft die Gleichheit.
Auswahl im Fenster. Im Panel steht nur das gewählte Symbol mit Vorschau, Namen und „Ändern …“. Das öffnet ein modales Fenster (<dialog>): Kategorien als Reiter links, Symbole mit Namen als Kacheln, Öffnen bei der Kategorie des gewählten Symbols mit Fokus darauf, Pfeiltasten in Reitern und Raster, Escape oder Klick auf den Rand schließt ohne Änderung, der Fokus kehrt zurück. Beim Button gibt es dort „Kein Symbol“. Symbol und Symbolkarte nutzen dasselbe Fenster.
Gestaltung unter Design (keine Schemaänderung). Neue Gruppe „Symbol“ im Style-Schema mit dem Ziel icon: Symbolfarbe, Fläche hinter dem Symbol (beide auch beim Überfahren), Symbolgröße, Innenabstand und Ecken der Fläche, Drehung (auch beim Überfahren), dazu Abstand zum Text am Button selbst. Damit gelten Gerätewerte, Vererbung, Hover, „Gestaltung kopieren“ und die Farbpalette wie überall. Der Compiler schreibt #id .button-icon{…} und #id:hover .button-icon,#id:focus-visible .button-icon{…}. Größe und Abstand laufen über --icon-size und --icon-gap, die site.css mit 1,15 em und 0,65 em als Vorgabe liest – ohne Angabe sieht alles aus wie vorher. Die Gruppe erscheint nur bei Buttons mit Symbol und ist dort aufgeklappt; ein Knopf im Inhalt führt hin.
Verhalten im Inhalt (Schema v26). iconMotion: none (ruhig), slide (rückt beim Überfahren in Leserichtung) oder reveal (erscheint erst beim Überfahren). iconOnly: ein Button nur aus Symbol; die Beschriftung steht dann als .button-label ohne Breite im Link – sie bleibt der zugängliche Name und hält die Zeilenhöhe, sodass der Knopf quadratisch und genauso hoch ist wie Text-Buttons daneben (zuerst mit aspect-ratio versucht: das zog die Höhe auf die Breite herunter). iconOnly ohne Symbol lehnt der Validator ab, und die Wahl „Kein Symbol“ nimmt es im Editor mit weg. Bewegungen folgen „Bewegung reduzieren“.
Geprüft: PHPUnit vollständig (616), darunter testAButtonIconMovesOnHoverOrStandsAlone, testButtonIconStylesTargetTheIconPerDeviceAndOnHover, testAButtonWithoutAnIconCannotBeIconOnly und neue Ablehnungsfälle. npm test (38). npm run test:button-icons im Browser: Panel ohne Kachelwand, Fenster mit sieben Kategorien und axe ohne Befund, Escape, Pfeiltasten, Stellung, Bewegung mit Rückgängig, nur Symbol, Design-Gruppe mit allen Werten und wirksamer Größe, Speichern als v26 und Neuladen, gemessenes Aussehen (Flächenfarbe, Rundung, Drehung beim Überfahren, quadratischer Nur-Symbol-Button). test:buttons und test:browser (Hüfner, Entwicklungsmodus) grün, HTML 148 KB unter dem Budget.
ADR 108 – Das Symbol am Button ist wählbar, ausgewählt wird mit Kacheln (07.10.2026)
Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – und dann: „Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche.“ Bis hierher hängte block.html.twig jedem Button fest arrow-up-right an, auch dem Textlink. Ändern ging nur über eigenes CSS (Hüfner blendet den Pfeil so aus, EB Solar drehte ihn).
Datenvertrag (Schema v25). Zwei neue, optionale Eigenschaften am Button: icon – ein Name aus DocumentValidator::ICONS oder none – und iconPosition – start oder end. Fehlt icon, bleibt es beim schrägen Pfeil (BUTTON_DEFAULT_ICON); fehlt iconPosition, steht das Symbol hinter dem Text. Ein Dokument ohne die beiden Felder sieht also genau aus wie vorher und braucht keinen Migrationsweg. Beide Felder verlangen v25, damit ein älterer Kern ein Dokument mit Symbolwahl ablehnt, statt es still mit Pfeil zu zeigen. Der Editor hebt das Dokument bei der ersten Wahl an, und repairSchema tut es beim Laden.
Kompatibilität mit eigenem CSS. Die Hülle des Symbols heißt jetzt button-icon. Der Standardpfeil trägt zusätzlich weiter button-arrow, damit Regeln wie & .button-arrow{display:none} auf bestehenden Websites wirken wie bisher. Wer dort ein anderes Symbol wählt, bekommt es sichtbar – die alte Regel trifft es nicht. Die Farb- und Größenregeln aus ADR 107 gelten jetzt für button-icon.
Symbolsatz. Dreizehn alltägliche Symbole kommen dazu: Pfeil nach unten, Winkel, Kalender, Nachricht, Senden, Download, Dokument, Abspielen, Suche, Herz, Info, Plus, Person. Sie stehen allen Bausteinen mit Symbol offen (Symbol, Symbolkarte, Button), nicht nur dem Button; das erweitert eine bestehende Liste und braucht wie ADR 107 kein neues Schema. Keine Markenzeichen (etwa WhatsApp): deren Logos haben eigene Nutzungsregeln.
Auswahl mit Kacheln. public/assets/icons.js hält Pfade, deutsche Namen und die Gruppen (Pfeile, Kontakt, Allgemein; bei Symbol und Symbolkarte zusätzlich „Weitere“) und zeichnet die Auswahl: jedes Symbol ein Knopf mit seiner Zeichnung und aria-pressed, „Kein Symbol“ vorn, Pfeiltasten wandern, Tab verlässt die Gruppe. Die bisherige Auswahlliste mit englischen Schlüsseln bei Symbol und Symbolkarte ist durch dieselbe Kachelauswahl ersetzt. Die Stellung ist ein Umschalter aus zwei Knöpfen und erscheint nur, wenn ein Symbol gewählt ist. Die Pfade stehen damit zweimal (Twig für die Website, JS für die Vorschau); tests/icons.test.mjs hält beide und die Liste im Validator gleich.
Geprüft: CmsTest::testAButtonCarriesTheChosenIconBeforeOrAfterItsTextOrNone (Standardpfeil mit button-arrow, Telefon vorn, kein Symbol), testAButtonRejectsUnknownIconsAndOlderSchemas, tests/icons.test.mjs, repairSchema hebt auf v25, npm run test:button-icons im Browser (Kachel, Stellung, Tastatur, Rückgängig, kein Symbol, Speichern als v25 und Neuladen).
ADR 107 – Knöpfe dürfen anrufen und schreiben, der Pfeil hat die Schriftfarbe (06.10.2026)
Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte.
tel: und mailto: als Linkziel von Bausteinen. Menüeinträge (MenuRepository::url), Links im formatierten Text (RichText::safeHref) und Plugin-Felder kannten beide längst. Nur die Linkziele der Bausteine – Button, Symbolkarte, Handlungsaufruf, Symbol, verlinktes Bild – liefen über DocumentValidator::url() und nahmen bloß Pfad, Sprungmarke und HTTPS. Ein Knopf „Jetzt anrufen“ ging deshalb nur als Textlink im Knopf-Aussehen. Jetzt prüft link() diese fünf Stellen: zuerst wie bisher, dazu tel: mit Ziffern, +, Klammern und Bindestrich (keine Leerzeichen, wie im Menü) und mailto: mit einer gültigen Adresse und höchstens einer prozentkodierten Abfrage (?subject=…). Bildquellen und eingebettete Inhalte bleiben bei url(): tel: ist dort kein sinnvolles Ziel.
Das Dokumentschema bleibt bei v24. Die Änderung erlaubt nur zusätzliche Werte in einem bestehenden Feld und braucht keinen Migrationsweg. Ein Dokument mit tel: an einem Knopf lässt sich aber auf einem Kern vor dieser Fassung nicht speichern. Eine Installation, die solche Dokumente bekommt, braucht also zuerst das Update.
Der Pfeil im Knopf folgt der Schriftfarbe. Das Pfeilsymbol trägt die Klasse block-icon, und die färbt jedes Symbol in der Akzentfarbe. Auf einem roten Knopf mit weißer Schrift stand so ein grüner Pfeil, und auch die Farbe beim Überfahren erreichte ihn nicht. .button-arrow .block-icon{color:inherit} in site.css setzt das richtig. Wo eine Website ihren Pfeil absichtlich anders färbt, tut sie das weiter über das eigene CSS des Knopfs; das geht vor.
Geprüft: CmsTest::testButtonsAndLinkedBlocksMayCallOrWrite (speichern, ausgeben, Bildquelle mit tel: abgewiesen), neue Ungültig-Fälle in invalidDocuments (Buchstaben oder Leerzeichen in der Nummer, mailto: ohne Adresse oder mit Anführungszeichen bzw. <), testTheButtonArrowTakesTheColourOfTheButtonText.
ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026)
Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte.
Den Rahmen gab es schon. Der Baustein embed („Externer Inhalt“, Schema v14) bettet eine fremde Seite ein: Adresse, Titel, Anbieter, Höhe – und davor die Einwilligungsfrage. Vor der Einwilligung wird nichts geladen, kein Bild und keine Anfrage; erst danach kommt der Host in frame-src der Sicherheitsrichtlinie. Ein <iframe>, das jemand als rohes Markup einfügt, hätte beides nicht: keine Frage, und der Browser lädt es gar nicht erst, weil frame-src den Host nicht kennt. Deshalb ist <iframe> im HTML-Baustein eine Fehlermeldung mit dem Weg zum richtigen Baustein – nicht etwas, das man einfügen kann und das dann schweigend nichts tut. Dasselbe gilt für <script>: das Feld „Eigenes Skript“ unter „Erweitert“ liefert es als eigene Datei aus, inline würde die Richtlinie es ohnehin blockieren.
Der Baustein (Schema v24). html mit einer einzigen Eigenschaft: das Markup, bis 20.000 Zeichen, unverändert ausgegeben. Es gibt keine Filterliste – wer ihn setzen darf, ist die eigentliche Antwort, und die ist dieselbe wie beim eigenen Skript: nur ein Administrator. Geprüft wird wie dort gegen den gespeicherten Stand (CustomCode::assertHtmlUnchanged()), in beiden Controllern und auch beim Zurückholen einer alten Fassung – ein Redakteur kann eine Seite mit HTML also weiter bearbeiten, ohne das HTML anfassen zu können. Im Editor ist das Feld für ihn sichtbar und schreibgeschützt, wie das Skriptfeld.
Warum keine Filterung wie bei Rich Text. Genau die Fälle, für die man den Baustein nimmt – ein fremdes Widget, eine eigene Tabelle, eine Auszeichnung, die es als Baustein nicht gibt – fallen durch jede Whitelist. Ein gefilterter HTML-Baustein hätte den Wunsch nicht erfüllt, sondern nur verschoben.
Folgen. README.md nennt den Baustein und seine Rolle. Nicht gebaut: eine Vorschau, die fremde Skripte ausführt (die Editorvorschau führt keine aus, das bleibt so), und eine Ausnahme von der Administratorregel für einzelne Seiten.
ADR 105 – Designpakete in Plugins zurückgenommen: das Design gehört der Website (16.09.2026)
Umut hatte auf der DEEG-Installation „Nachtblau“ eingeschaltet und wieder ausgeschaltet – und die Farben blieben. Die Ursache war nicht der Schalter (activate()/deactivate() fassen das Design nie an), sondern der zweite Knopf daneben: „Designpaket übernehmen“ schrieb die Palette des Plugins über Theme::save() endgültig in die Website. Technisch war das gewollt (ADR 103: ein Import, damit sich das Ergebnis unter Design weiter anpassen lässt und beim Ausschalten nichts verlorengeht) – in der Bedienung war es eine Falle: eine Aktion, die die gesamte Palette ersetzt, ohne Rückfrage und ohne Weg zurück, auf einem Bildschirm, wo daneben ein Schalter sitzt, den man gefahrlos umlegen kann.
Entscheidung: theme fällt aus dem Manifest. Kein themePackage(), kein applyTheme(), keine Route admin_plugin_theme, kein Knopf auf dem Bildschirm „Plugins“, kein plugins/nachtblau. Ein Manifest braucht jetzt mindestens einen Baustein, eine Adresse oder eine Vorlage. Der Alternativweg – Sicherung der alten Palette plus „Design zurücksetzen“ – wäre eine Spalte, eine Migration und ein zweiter Zustand gewesen, um eine Funktion zu retten, die niemand vermisst: Farben, Schriften und Buttons ändert man unter Design, dort mit Vorschau und in einem Schritt rückgängig.
Folgen. Ein Plugin kann das Aussehen der Website nur noch über das CSS seiner eigenen Bausteine beeinflussen – nie über die Palette. Der Test dazu heißt jetzt umgekehrt: Ein- und Ausschalten lässt Design und Inhalte unangetastet (PluginTest::testSwitchingAPluginOnAndOffLeavesTheDesignAlone). Bestehende Installationen behalten die Palette, die sie haben; wer ein übernommenes Paket loswerden will, setzt die Farben unter Design. Auf der DEEG-Installation ist die eigene Palette (Logo-Blau #0081c7, Anthrazit, Arial, eckige Buttons) aus dem Aufbauskript wieder eingespielt. Die Abnahme „Templatewechsel erhält die Inhalte“ aus dem Ausbauplan gilt damit als nicht gebaut; ein Templatewechsel gehört, wenn er kommt, in den Designbereich und nicht in ein Plugin.
ADR 104 – Die Rückstände der Gesamtprüfung abgearbeitet: Archiv als Seite, Bereiche vollständig, Palette bis in die Fehlerfarbe, Betrieb auf MySQL (16.09.2026)
Umut: „Bitte umsetzen“ – die Liste aus ADR 100, die bis dahin nur dokumentiert war.
Archivseiten sind Seiten. /beitraege, /kategorie/… und /schlagwort/… bauen ein Dokument aus Bausteinen (Kopfabschnitt mit Überschrift und Zahl, ein Abschnitt mit blätternder Beitragsliste) und geben es an renderPage() – dieselbe Aufbaukette wie jede Seite: Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, eigenes CSS, Schriften, Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Skripte, Richtlinie. Die eigene Vorlage archive.html.twig ist weg. Ein Archiv hat den Typ archive; Brotkrumen führen „Beiträge → Kategorie“, und das steht als BreadcrumbList in den strukturierten Daten.
Blättern ist eine Folge von Seiten. Hat eine Seite eine blätternde Beitragsliste, nennt die Canonical ab Seite 2 die Seitenzahl (?seite=n), und <link rel="prev">/<link rel="next"> verbinden die Folge. Vorher hielt eine Suchmaschine Seite 3 für ein Duplikat von Seite 1.
Bereiche sind vollständig. Formulare, Beitragslisten und Brotkrumen in Kopf, Fuß und verknüpften Sektionen werden jetzt wie im Inhalt aufgelöst; die Meldungen eines abgewiesenen Formulars finden ihren Block über die Formularadresse statt über die Block-ID, wo auch immer er steht. Eigene Skripte eines Bereichs laufen als /assets/bereich/<id>.js – vorher wurden sie gespeichert, geprüft und nie ausgeführt.
Mehrfach verknüpfte Sektionen ohne doppelte IDs. PartialRepository::expandRepeatedLinks() zählt in Renderreihenfolge (Kopf, Inhalt, Fuß, verschachtelt bis zur Grenze) und gibt der zweiten und jeder weiteren Verknüpfung eine Kopie mit angehängten Kennungen (id-2) und ohne Sprungziele; ihre Stile werden mitkompiliert. Auch die Editorvorschau macht das.
Eine Grenze zwischen Tablet und Telefon: 767 px. Design-Tokens, Website-Stile, Slider und Galerie sprangen bei 650, der Stilcompiler und die Sichtbarkeit bei 767 – zwischen 651 und 767 galt die Hälfte. Jetzt gilt überall 1024/767, wie es der Editor schon immer sagte.
Die Palette reicht bis in die Fehlerfarbe. color-scheme, Skip-Link, Aufruf-Button, die Fehlerfarben der Formulare und der Text auf dunklen Abschnitten kommen aus Variablen; Theme::css() leitet aus der Leuchtdichte des Papiers ab, ob das Schema dunkel ist und welches Rot dort steht (--k-scheme, --k-error, --k-error-bg, --k-error-line). Buttons dunkeln beim Überfahren in die Tinte statt in Schwarz. Neu: Druckstile – Menü, Einwilligung, Nach-oben und Formularknöpfe bleiben weg, Verweise zeigen ihre Adresse.
Betrieb. Sicherung und Wiederherstellung auf MySQL/MariaDB über mysqldump --single-transaction --result-file und mysql --execute=source; damit laufen Archiv-Update und Rückweg auch dort. Der Git-Updater sichert über Backup (VACUUM INTO bzw. Dump) statt mit einer rohen Dateikopie und spielt beim Rückweg dieselbe Sicherung ein. Eine Sperre (var/update.lock, flock) hält den zweiten Klick und den Archiv-Weg neben dem Git-Weg fern. Das Archiv wird direkt in eine Datei geladen (Download::toFile()), nicht mehr als Zeichenkette in den Speicher. public/.htaccess wird zusammengeführt: eingeschaltete HTTPS- und HSTS-Zeilen bleiben eingeschaltet. Nach einem gelungenen Update räumt finish() var/update/ (bis auf die zwei jüngsten) und die Zwischenspeicher früherer Fassungen auf. Der MySQL-Job der CI prüft jetzt Gesundheit, Sicherung und die Tests.
Editor. Die Vorschau zeigt eigenes CSS, Bereichs- und Sektionsstile und Formulare mit ihren Farben – „sieht in der Vorschau anders aus“ ist keine Ausrede mehr. Kontaktdaten, Formular, verknüpfte Sektion und externer Inhalt sind direkt nach dem Einfügen gültig: leer heißt „noch nichts gewählt“, der Editor sagt es, die Website zeigt nichts. Der Server gibt nach dem Speichern die bereinigte Fassung zurück, und der Editor übernimmt sie als gespeicherten Stand (EditorHistory.adopt()), statt sie erst nach dem Neuladen zu zeigen.
Kleines. legal.label ist auf 60 Zeichen begrenzt, die verrutschten Docblocks in PageController stehen an ihren Methoden.
Folgen. tests/BacklogTest.php hält alles fest. Nicht gebaut: eine Wiederherstellung auf MySQL ohne mysql-Client (dann liegt der Dump zum Einspielen von Hand bereit), Druckstile für Beiträge mit Bildkopf.
ADR 103 – Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen und Designpakete für Plugins (Designpakete zurückgenommen, ADR 105) – und warum es keine PHP-Hooks gibt (16.09.2026)
Umut: „Dann mach H.“ Der erste Teil (ADR 102) brachte Manifest, Schalter und geprüfte Bausteine. Offen waren Container, Einstellungen, eigene Routen, Tabellen, Hooks und Templates.
Grundentscheidung: ein Plugin bleibt Manifest plus Twig. Es bringt keinen PHP-Code mit. Das ist kein Zufall, sondern die Antwort auf zwei Punkte des Entwicklungsplans zugleich: Ein Plugin ohne PHP kann nicht als „sandboxed“ bezeichnet werden müssen, weil es gar nichts ausführt, was der Kern nicht ohnehin ausführt (Twig mit strict_variables, ohne Zugriff auf Datenbank, Anfrage oder Dateisystem). Und ein Update des Kerns kann ein solches Plugin nicht brechen, solange die vier Übergaben stehen – props, settings, children, block. Damit fallen zwei Wünsche des Plans bewusst weg: Hooks in Speichern und Veröffentlichen und eigene Tabellen. Beides braucht Code, der im Kern läuft. Wer das braucht, baut einen Kernbaustein (ADR 049) oder wartet auf einen dritten Teil mit einer echten Erweiterungsschnittstelle in PHP – dann mit der Ehrlichkeit, dass er Installationscode ist.
Container. "container": true am Baustein, optional "children": [Typen]. Der Kern rendert die Kinder selbst – mit demselben Kontextbündel wie jeden Kernbaustein – und reicht sie der Vorlage fertig als children. So kommt eine Plugin-Vorlage nie an Formulare, Beitragslisten oder die Navigation. Beim Schreiben prüft DocumentValidator, dass ein Nicht-Container keine Kinder trägt und ein Container nur die erlaubten; der Editor spiegelt die Regel in canContain, also im Werkzeugkasten und beim Ziehen.
Einstellungen. "settings": {Schlüssel: Schema} mit denselben sieben Arten wie Eigenschaften. Der Bildschirm „Plugins“ zeigt für jedes aktive Plugin ein Formular daraus; gespeichert wird in der neuen Spalte plugin.settings (Migration 20260916000100), geprüft gegen das Schema wie eine Eigenschaft – aus einem Formular kommen Zahlen und Schalter als Text, das wird umgewandelt. Vorlagen und Adressen bekommen die Werte als settings, fehlende Schlüssel mit ihrer Vorgabe.
Eigene Adressen. "routes": {name: {path, template, type}}. Der Pfad hat die Form einer Seite (Slugs, bis zu vier Ebenen) und darf nichts belegen, was der Kern beantwortet; die Antwortart ist eine aus html, json, text, xml. PageController::page() fragt die Registry, wenn es zu einem Pfad keine veröffentlichte Seite gibt – eine Seite geht also immer vor, die Adresse kommt vor Weiterleitung und 404. Die Vorlage bekommt settings, die Abfrageparameter (nur saubere Namen, gekürzt) und die Route; die Antwort trägt dieselben Schutzkopfzeilen wie jede Seite, kein Skript, bei allem außer html noindex. Ausgeschaltet: 404.
Seitenvorlagen. "presets": {name: {label, blocks}} – fertige Bausteine, die der Editor als Ganzes einfügt, hinter den eingebauten Vorlagen mit demselben Knopf. Jedes Einfügen vergibt frische Kennungen; geprüft wird beim Speichern wie jedes Dokument. Ein Plugin kann so seinen Baustein gleich im passenden Abschnitt anbieten.
Designpakete. (Zurückgenommen in ADR 105 – es gibt sie nicht mehr.) "theme": {colors, fonts, base, buttons} im Format des Designbereichs. „Designpaket übernehmen“ auf dem Bildschirm „Plugins“ ruft Theme::save() – dieselbe Prüfung wie beim Speichern unter Design, dieselben Fehler. Inhalte bleiben unangetastet; genau das ist die Abnahme des Plans („Templatewechsel erhält die Inhalte“), und der Test wechselt auf einer Seite mit Plugin-Baustein das Paket und vergleicht das Dokument davor und danach. Ein Plugin darf nur aus einem Designpaket bestehen (plugins/nachtblau); übernehmen geht nur eingeschaltet.
Referenz. plugins/vergleich 1.1.0 hat jetzt Einstellungen (Standardbeschriftungen, Griff), einen Container „Vergleichsgalerie“ nur für Bildvergleiche, die Vorlage „Vorher-Nachher-Abschnitt“ und die Adresse /vergleich/daten (JSON mit den Einstellungen). plugins/nachtblau war das Designpaket und ist mit ADR 105 entfallen.
Folgen. docs/PLUGINS.md beschreibt alles. Nicht gebaut und nicht geplant, solange Plugins ohne PHP bleiben: Hooks, Tabellen, Ressourcen in der Verwaltung, eigene Verwaltungsbildschirme.
ADR 102 – Das Pluginsystem, erste Fassung: Manifest, Schalter, geprüfte Bausteine (15.09.2026)
Umut: „Plugin hätte ich gerne aber umgesetzt. Also Möglichkeit.“ Bis hierher gab es nur den Erhaltungsvertrag aus ADR 011: ein Baustein mit Namensraum wurde aufbewahrt, aber nie gerendert. Jetzt gibt es die Laufzeit dazu – Arbeitspaket H, erster Teil.
Ein Plugin ist ein Ordner. plugins/<name>/plugin.json beschreibt Titel, Fassung, kleinste KantSites-Fassung und die Bausteine: je Baustein Beschriftung, Symbol, Twig-Vorlage, optional ein Stylesheet und ein Skript unter assets/, und ein Schema der Eigenschaften mit sieben Arten (Text, mehrzeilig, Zahl mit Grenzen, an/aus, Auswahl, Adresse, Bild aus der Mediathek). Der Typ des Bausteins ist <plugin>.<baustein> – dieselbe Form, die der Erhaltungsvertrag seit Schema v4 kennt. PluginRegistry liest die Manifeste bei jedem Aufruf (ein Dutzend Dateien, kein Cache nötig) und meldet ein kaputtes Manifest mit Grund statt es zu übergehen; ein solches Plugin lässt sich nicht einschalten.
Der Schalter liegt in der Datenbank. Migration 20260915000100 legt plugin an (Name, aktiv, Fassung beim Einschalten, Zeitpunkt). Ob ein Plugin läuft, entscheidet die Installation, nicht der Code – und nicht eine Datei, die ein Update überschreiben könnte. Der Bildschirm „Plugins“ (Administrator) zeigt jeden Ordner mit seinen Bausteinen und schaltet ein und aus; ein ausgeschaltetes Plugin lässt seine Bausteine unangetastet in den Dokumenten (Erhaltungsvertrag wie bisher).
Der Kern prüft, die Vorlage vertraut. Beim Schreiben eines Dokuments prüft DocumentValidator die Eigenschaften eines Plugin-Bausteins gegen das Schema – so streng wie bei einem Kernbaustein, aber nur, wenn das Plugin aktiv ist. Beim Lesen nicht (validate(..., writing: true) nur an den Schreibstellen): ein Plugin, das nach dem Speichern eingeschaltet oder geändert wurde, darf eine gespeicherte Seite nicht unlesbar machen. Dass unpassende Werte trotzdem nie in die Ausgabe gelangen, sichert der Renderer: kant_plugin_props(block) prüft beim Rendern erneut und liefert bei einem Fehler null – dann steht ein Kommentar statt des Bausteins. Die Vorlage bekommt genau vier Dinge: die geprüften props, block (für die id), designClasses und editing, dazu kant_plugin_media() für Bilder. Keine Datenbank, keine Einstellungen, keine Anfrage. Unbekannte Eigenschaften in einem gespeicherten Dokument werden ignoriert, damit ein Plugin eine Eigenschaft streichen kann.
Dateien nur dort, wo der Baustein steht. PageController sammelt aus allen Dokumenten der Seite die Plugin-Bausteine und verlinkt ihre Stylesheets und Skripte (/plugin-assets/<name>/assets/...?v=<Fassung>); ein Skript schaltet die Richtlinie auf script-src 'self', sonst bleibt sie bei 'none'. PluginAssetController liefert nur Dateien aktiver Plugins unter assets/ mit erlaubter Endung, mit Typ, nosniff und einem Jahr Haltbarkeit; Manifest und Vorlagen sind nicht erreichbar. Plugins liegen bewusst außerhalb von public/. In der Editorvorschau wird das Stylesheet geladen, das Skript nicht – wie bei eigenem Code.
Der Editor kennt die Bausteine aus dem Dokumentaufruf. Der Editor-Endpunkt liefert mit dem Dokument die Definitionen der aktiven Plugins; registerPlugins() trägt Beschriftung, Symbol und Vorgaben ein und gibt ihnen die Gruppe „Erweiterungen“ im Werkzeugkasten. Das Eigenschaftenpanel entsteht aus dem Schema mit derselben Feldfabrik wie für Kernbausteine; ein Bild geht über die Mediathek. Ein Baustein, dessen Plugin fehlt, bleibt der bekannte Platzhalter. Design-Tokens (Abstände, Ausrichtung, Ecken) gelten jetzt auch für Plugin-Bausteine – die Klassen sind allgemein, die Prüfung lässt namensraumhaltige Typen durch.
Kein Sandkasten, und das steht so da. Eine Plugin-Vorlage läuft mit den Rechten der Anwendung. Der Entwicklungsplan verlangt, das nicht anders zu nennen; der Bildschirm „Plugins“ sagt es dem Betreiber.
Referenzplugin „Bildvergleich“ (plugins/vergleich): zwei Bilder übereinander, ein Regler dazwischen – Beschriftungen, Startposition, Ecken als Eigenschaften, ein Stylesheet, ein Skript, das nur --pos über das CSSOM setzt (Inline-Stile bleiben durch die Richtlinie verboten). Ohne Skript steht der Vergleich bei der Startposition, das native Bereichsfeld bleibt bedienbar.
Updates. Das Releasearchiv enthält plugins/; das Archiv-Update übernimmt den Ordner Datei für Datei wie public/, sodass mitgelieferte Plugins aktualisiert werden und ein selbst installiertes stehen bleibt.
Nicht in dieser Fassung: Container-Bausteine, eigene Routen, Einstellungen, Tabellen, Hooks in Speichern und Veröffentlichen, Ressourcen in der Verwaltung, Templates (der zweite Teil von H). Anleitung für Entwickler: docs/PLUGINS.md.
ADR 101 – Formulare mit Schritten und Bedingungen: der Preisfinder ist ein Formular, kein Plugin (15.09.2026)
Umut wollte für die DEEG-Website „kein Kontaktformular weiteres, sondern so ein ausgebautes Form“ – den Preisfinder der laufenden Website: erst die Objektart, dann Eckdaten je Objektart, dann Kontakt, zum Schluss eine Zusammenfassung. Und er fragte, ob das ein eigenes Plugin braucht.
Warum kein Plugin. Ein Plugin ist in diesem CMS bis heute nur der Erhaltungsvertrag aus ADR 011: ein Baustein mit Namensraum wird gespeichert, im Editor als Platzhalter gezeigt und öffentlich als Kommentar ausgegeben. Registrierung, Laufzeit, Eigenschaftenpanel und Ressourcenladen sind Arbeitspaket H und nicht gebaut. Ein Preisfinder als Plugin hätte zuerst das ganze Pluginsystem gebraucht – und wäre dann eine Hülle um genau das, was der Formularbaukasten schon kann: Felder prüfen, speichern, verschicken, anzeigen. Wie bei der Beitragsseite (ADR 049) ist die Antwort ein Ausbau des Kerns. Ein Formular, das in Schritten fragt und Felder je nach Antwort zeigt, braucht jede Kundenwebsite.
Was dazukam. Drei Feldtypen: Zahl (mit Unter-, Obergrenze und Einheit), Auswahl als Optionsfelder und Mehrfachauswahl. Ein Feld vom Typ „Neuer Schritt“ eröffnet einen Abschnitt: seine Beschriftung ist der Titel, sein Hilfetext die Einleitung, alle Felder bis zum nächsten Schritt gehören dazu. Jedes Feld und jeder Schritt kann eine Bedingung tragen (showIf: {field, values}): es gilt nur, wenn ein früheres Auswahlfeld einen der Werte hat. Und eine Einstellung „Zusammenfassung vor dem Absenden“.
Der Server entscheidet, nicht das Skript. FormRepository::check() geht die Felder in Reihenfolge durch und merkt sich die gewählten Optionen; ein Feld hinter einer Bedingung, die nicht zutrifft, wird weder verlangt noch geprüft noch gespeichert – auch wenn der Browser einen Wert mitschickt. Ein Schritt hinter einer Bedingung nimmt seine Felder mit. Beim Speichern der Definition wird jede Bedingung geprüft: das Feld muss davor stehen, ein Auswahlfeld sein, und jeder Wert eine seiner Optionen – sonst könnte die Bedingung nie zutreffen, und ein Pflichtfeld dahinter wäre unerreichbar oder immer offen. Optionen dürfen kein Komma enthalten, weil die Verwaltung daran trennt und die Mehrfachauswahl damit gespeichert wird.
Ohne Skript vollständig. Die Vorlage rendert Schritte als fieldset mit legend, Optionsfelder und Mehrfachauswahl als Gruppen, Zahlen als type="number" mit Grenzen; Bedingungen stehen als Datenattribute am Feld. Ohne JavaScript steht das ganze Formular auf der Seite, jeder Schritt ein Abschnitt, alles absendbar – der Server prüft ohnehin selbst. form-steps.js kommt nur auf Seiten, deren Formular Schritte, Bedingungen oder die Zusammenfassung hat (FormRepository::needsScript()), und die Richtlinie erlaubt es dann; es zeigt einen Schritt nach dem anderen mit Fortschritt, „Zurück“ und „Weiter“, prüft den aktuellen Schritt mit der Browserprüfung, deaktiviert Felder hinter nicht zutreffenden Bedingungen (deaktiviert, nicht nur versteckt – sie werden nicht gesendet und nicht geprüft) und baut als letzten Schritt die Zusammenfassung aus den geltenden Feldern mit „Ändern“ je Schritt. Nach einer Ablehnung durch den Server öffnet es den ersten Schritt mit einem Fehler. Eine Pflicht-Mehrfachauswahl trägt required nicht am Kästchen – das hieße „alle ankreuzen“ – sondern als data-required an der Gruppe; Skript und Server prüfen sie dort.
Bildauswahl. Umut zeigte den Solar-Rechner von Elektro Bernhard: „Wieviele Personen leben im Haushalt“ als vier Bildkarten, die gewählte mit Rahmen. Optionsfelder und Mehrfachauswahl tragen dafür je Option ein Bild aus der Mediathek (images: {Option: Kennung}); sobald eine Option eines hat, rendert die Vorlage die Auswahl als Karten mit Bild und Text. Das native Kästchen bleibt in der Karte – sichtbar oben links, mit Fokusring auf der Karte –, damit Tastatur und Vorlesesoftware dieselbe Auswahl bedienen wie die Maus. Die Seite holt die kleinsten Fassungen aller Optionsbilder eines Formulars mit einer Abfrage (PageController::optionImages()); ein gelöschtes Bild lässt die Karte mit Text stehen. In der Verwaltung wählt man das Bild je Option aus einer Liste der Mediathek; ohne Skript ist es eine Zeile „Option = Kennung“.
Rückweg. Bestehende Definitionen bleiben gültig: present() ergänzt fehlende Schlüssel (min, max, unit, images, showIf, summary), es gibt keine Migration. Ein Formular ohne Schritte und Bedingungen rendert wie zuvor und lädt kein Skript. Die Höchstzahl der Felder steigt von 25 auf 40; ein Preisfinder mit vier Objektarten braucht sie.
Folgen. Die Editorvorschau zeigt Formulare weiterhin nicht (bekannter Rückstand). Der Preisfinder der DEEG-Website ist damit ein gewöhnliches Formular bewertung mit drei Schritten, 20 Feldern und Zusammenfassung, angelegt im Aufbauskript und in der Verwaltung ohne Skript bearbeitbar. Nicht gebaut: Bedingungen auf Zahlenbereiche oder mehrere Felder, Dateiuploads, ein Schritt-für-Schritt-Ablauf ohne JavaScript.
ADR 100 – Gesamtprüfung: Rollen wie versprochen, Installer bleibt zu, Einwilligung nur von hier (15.09.2026)
Anlass: Umut wollte eine umfangreiche Prüfung – Fehler, Durchgängigkeit, Sauberkeit – und ausdrücklich die Frage, ob jemand ohne Administrator etwas verändern kann. Sechs getrennte Durchsichten (Backend, Betrieb, Editor, öffentliche Ausgabe, Dokumentation, Autorisierung jeder Route) lieferten rund sechzig Befunde; die mit Wirkung auf Sicherheit oder Bedienbarkeit sind umgesetzt, der Rest steht in STATUS als Rückstand.
Rollen. Die Rollenhilfe versprach „Design, Einstellungen: Administrator“ und „Redakteur: nur Entwürfe“, der Code verlangte für Design, Einstellungen, Bereiche, Menüs und Formulare aber nur ROLE_EDITOR – ein Redakteur konnte Kopf und Fuß live umschreiben, die Empfängeradresse der Formulare ändern, die Website per robotsExtra aus dem Index nehmen. Entscheidung: der Code folgt der Hilfe. Redakteur = Entwürfe von Seiten und Beiträgen, Medien, Statistik, eigenes Konto. Veröffentlicher = dazu veröffentlichen, Bereiche (Speichern ist dort Veröffentlichen), Menüs, Formulare, Weiterleitungen, 404-Seite, Einstellungen lesen. Administrator = dazu Design, Unternehmensdaten samt Einwilligung und Spamschutz, Benutzer, System, Aufgaben, eigene Skripte, endgültiges Löschen. Die Navigation zeigt je Rolle nur, was sie öffnen darf; die Unternehmensdaten sind für Veröffentlicher ein Hinweis statt eines Formulars. tests/EditorSecurityTest.php hält die Grenze mit einer Tabelle „Rolle, Route, 403“ fest, dazu die Navigation eines Redakteurs und den Weg ohne Anmeldung.
Installer. Installation::isInstalled() las jede Ausnahme als „nicht installiert“ – auch eine tote Datenbankverbindung. Bei einer Wartung des Hosters oder einem gedrehten Passwort stand der Installer offen, und jeder Besucher hätte eine eigene Datenbank samt Administratorkonto eintragen können. Jetzt zählt die Marke var/installed immer, eine fehlende Tabelle heißt „leer“, eine fehlende Verbindung heißt „gestört“, sobald eine Datenbank eingetragen ist. tests/InstallationTest.php prüft alle vier Lagen.
Einwilligung. /einwilligung hat keine Sitzung und deshalb kein CSRF-Token; ein fremdes Formular konnte jedem Besucher „alles akzeptiert“ ins Cookie schreiben. Die Route liest jetzt Sec-Fetch-Site, ersatzweise Origin/Referer, und merkt sich nur Entscheidungen von dieser Website; ohne Herkunftsangabe (alte Browser) bleibt sie beantwortbar. Der Rücksprung akzeptiert nur Pfade, die weder mit // noch mit /\ beginnen – Browser lesen beide als protokollrelativ.
Ausgabe. JSON-LD wird mit JSON_HEX_TAG|JSON_HEX_AMP kodiert: ein </script> in Titel oder FAQ-Antwort hätte den Datenblock beendet und den Rest des Kopfes als Markup gerendert. Eigenes CSS erlaubt im verschachtelten Selektor kein Komma mehr (&,body{…} wurde zu #id,body{…}). Eine Fassung wiederherstellen prüft Skripte wie das Speichern. Das Archiv escaped alte Klartext-Bereiche wie die Seiten. Tabs und Bildbetrachter in Kopf, Fuß oder verknüpfter Sektion bekommen Skript und Richtlinie.
Betrieb. %env(resolve:…)% las prozentkodierte Zugangsdaten (p%40ss) als Parameter – der Container brach nach dem Installer bei jeder Anfrage ab. Ein eigener Prozessor projectdir: ersetzt nur %kernel.project_dir% (src/Infrastructure/ProjectDirEnvProcessor.php); MAILER_DSN kommt ohne Prozessor aus. Das Sitzungscookie folgt der Adresse der Verwaltung (cookie_path: '/%kant.admin_path%') – nach cms:admin:path war vorher keine Anmeldung möglich. Downloads bleiben auch über Weiterleitungen bei HTTPS. Der Befehlsläufer gibt Composer und Git ein Zuhause (HOME, COMPOSER_HOME, APPDATA) und sucht die PHP-Kommandozeile, weil PHP_BINARY unter php-fpm der Server selbst ist. Ein unerwarteter Fehler im Updater nimmt den Rückweg statt den Wartungszustand stehen zu lassen.
Editor. upgradeForType hob das Schema auf die Fassung, in der ein Typ auftauchte, nicht auf die, die seine heutigen Eigenschaften brauchen – Menü (v16), Beitragsliste (v18) und Slider (v17) waren in älteren Dokumenten sofort ungültig. controls() gab nach jedem Speichern alle Knöpfe des Bedienfelds frei, auch die, die das Design-Bedienfeld bewusst gesperrt hatte („Gestaltung einfügen“ ohne Zwischenablage löschte die Gestaltung, „Als Vorlage speichern“ ohne Gestaltung löschte eine Vorlage gleichen Namens); jetzt merkt sich die Sperre, was sie geschlossen hat. „Ausrücken“ und ein Zug neben ein Element der obersten Ebene folgen denselben Regeln wie jeder andere Zug. Einfügen prüft das Ziel, bevor es das Schema hebt, sonst blieb bei einer Ablehnung ein leerer Verlaufsschritt. Ein Bild auf ein Akkordeon meldet sich statt zu werfen. Klartext unter v12 wird beim Öffnen nicht als HTML gelesen.
Bewusst nicht in diesem Schritt: Archivseiten auf dieselbe Aufbaukette wie Seiten (eigene Stile von Kopf/Fuß, Schriften, Sektionen), Formulare und Beitragslisten in Bereichen, doppelte Block-IDs bei mehrfach verknüpften Sektionen, die zwei Breakpoints (650/767 px), feste Farbwerte in dunklen Paletten, MySQL-Sicherung, .htaccess als Betreiberdatei beim Archiv-Update, Wachstum der Release-Caches, Druckstile, Canonical blätternder Listen – Umfang und Risiko rechtfertigen je einen eigenen Schritt (Rückstand in STATUS).
ADR 099 – Mit oder ohne www entscheidet SITE_ORIGIN, nicht der Webserver (15.09.2026)
Anlass: Umut: „URL sollte aus env kommen, www oder ohne, die Seite sollte dann darauf reagieren.“ Bis dahin stand die Umleitung auskommentiert in public/.htaccess und musste von Hand zur Einstellung SITE_ORIGIN passen (LIVEGANG, Punkt 3). Zwei Stellen für eine Entscheidung laufen auseinander – und dann nennt jede Seite in Canonical, Sitemap und Feed eine Adresse, die weiterleitet.
Entscheidung: SITE_ORIGIN in .env ist die einzige Quelle. CanonicalHostSubscriber (kernel.request, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von SITE_ORIGIN und schickt die jeweils andere Schreibweise – example.de ↔ www.example.de – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus SITE_ORIGIN. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt Cache-Control: public, max-age=3600, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in .htaccess ist weg; die Datei erklärt, wer es jetzt macht. php bin/console cms:site:origin <adresse> prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie cms:admin:path es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft. Seit 1.21.8 lässt sie sich dort auch ändern, wie die „WordPress-Adresse“: Das Feld schreibt über Environment::write in .env, aber erst nachdem OriginCheck die Installation unter der neuen Adresse erreicht hat (GET /.well-known/kantsites-origin liefert einen HMAC aus APP_SECRET). Eine Umstellung auf www ohne DNS oder Zertifikat würde sonst jede Seite samt Verwaltung ins Leere leiten. „Trotzdem übernehmen“ erscheint erst nach einer Ablehnung. Kommt SITE_ORIGIN aus der Server-Umgebung, gibt es kein Feld.
Bewusst nicht: ein fremder Host wird nicht umgeleitet. Die Vorschauadresse des Hosters, eine IP-Adresse, localhost, eine Subdomain – alles bleibt erreichbar, auch wenn SITE_ORIGIN etwas anderes nennt. Sonst sperrte eine falsch eingetragene Adresse jeden aus, samt der Verwaltung, die man bräuchte, um es zu bemerken. Nur lesende Anfragen (GET, HEAD) werden umgeleitet: ein 301 macht aus einem POST ein GET und verwirft die Eingabe. Und HTTPS erzwingt die Anwendung nicht: hinter einem Proxy sieht PHP oft nur http, ohne konfigurierte vertrauenswürdige Proxys wäre das eine Schleife. Die drei Zeilen dafür bleiben in .htaccess, wo der Webserver die Verbindung wirklich kennt.
Folgen: tests/CanonicalHostTest.php (Paar, Fremdhost, Schema, Port, Methoden, Priorität), tests/SiteOriginCommandTest.php, je ein Fall in CmsTest (durch den Kernel) und SystemScreenTest (Anzeige und Hinweis). Die Startkonfigurationen der lokalen Installationen setzen SITE_ORIGIN auf ihre 127.0.0.1:<port>-Adresse und sind davon nicht betroffen.
ADR 098 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
Anlass: Umut: „bei Rows die Breite ändern – am besten per Drag and Drop im Editor.“ Bis dahin gab es nur gleich breite Spalten: die klassische Vorgabe columns (one/two/three/four) und die freie Eigenschaft gridColumns erzeugten beide repeat(n, minmax(0, 1fr)). Ein Symbol neben einem Text – die Karte aus seinem Beispiel – brauchte eine schmale und eine breite Spalte.
Entscheidung: Eine neue freie Stileigenschaft columnWidths am Zeilenblock, Typ fractions: bis zu zwölf Zahlen mit Leerzeichen, 2 1 oder 66.7 33.3, je Gerät wie jede andere freie Eigenschaft (StyleSchema::FRACTIONS, Prüfung in StyleCompiler::scalar(), null ist ausgeschlossen). Die Anteile liegen an der Zeile und nicht an den Spalten, weil eine Grenze immer zwei Spalten zugleich verändert und die Reihenfolge der Kinder die Zuordnung ist. Kein neues Dokumentschema: die Eigenschaft fällt unter die freien Stile ab v10.
CSS. StyleCompiler::columnTracks() schreibt je Gerät #id{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}. Zwei Feinheiten: Fehlende Anteile (eine später eingefügte Spalte) bekommen den Durchschnitt, überzählige fallen weg, also passt die Regel immer zur Kinderzahl. Und die Desktop-Regel trägt eine ID und würde die Klasse .d-mobile-columns-one schlagen, mit der ein Handy die Spalten stapelt – deshalb bekommt ein Gerät, dessen wirksame Aufteilung one (klassisch) oder gridColumns 1 ist, unter seiner Medienabfrage minmax(0,1fr). Die Abfragen sind die des Stilcompilers (1024/767 px), nicht die 650 px der klassischen Klassen; zwischen 651 und 767 px stapelt eine Zeile mit Anteilen also, wo sie ohne Anteile noch zwei Spalten hätte. Das ist gewollt: Querformat-Handys sind Handys.
Editor. canvas-overlay.js zeichnet für die gewählte Zeile – oder die Zeile um die gewählte Spalte – einen Griff auf jede Grenze zwischen zwei nebeneinander liegenden Spalten (.ov-resize, ein Knopf mit Beschriftung „Breite zwischen Spalte 1 und 2 ändern“). Ausgangswert ist die gemessene Breite der Spalten, nicht der gespeicherte Wert, damit der Griff auch bei geerbten oder klassischen Aufteilungen dort anfängt, wo die Spalten wirklich stehen. Während des Ziehens wird die Zeile im Vorschaudokument inline umgestellt (grid-template-columns), erst beim Loslassen entsteht ein Verlaufsschritt (onResize → setStyle(row, device, 'columnWidths')); die Vorschau lädt danach vom Server neu. Pfeiltasten verschieben um fünf Punkte und behalten den Fokus auf dem Griff über die Neuzeichnung hinweg. Kein Griff, wo die Spalten gestapelt sind, und keiner während ein Element gezogen wird. Im Design-Bedienfeld steht derselbe Wert als Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen), für alle, die lieber Zahlen tippen.
Verworfen: Anteile je Spalte (span aus zwölf) wie in Flatsome – zwei Werte je Grenze zu pflegen und eine Summe, die 12 ergeben muss, sind für ein Drag-Ergebnis unnötig streng; Anteile summieren sich beliebig. Eine eigene Klasse je Verhältnis (1-3, 2-3) – deckt genau die Fälle ab, die jemand vorhergesehen hat, und das Ziehen erzeugt jeden anderen.
ADR 097 – Der Einwilligungshinweis bekommt ein Gesicht, und der Nach-oben-Knopf weicht ihm aus (15.09.2026)
Anlass. Umut gefiel das Banner nicht: nicht gestaltbar, ein kantiger Kasten in Papierfarbe. Und der Nach-oben-Knopf „funktionierte auf dem Handy nicht“ – er war ausgeblendet, solange die Frage offen stand, und auf dem Telefon bleibt sie das oft.
Gestaltung aus den Einstellungen, nicht aus dem Editor. Der Hinweis ist kein Baustein einer Seite, sondern eine Frage der Website; seine Gestaltung gehört deshalb neben seinen Text unter Einstellungen → Einwilligung: Anordnung (karte, leiste, ecke), Form (weich, rund, eckig), Überschrift, vier Farben (Fläche, Schrift, Knopf, Schrift auf dem Knopf). Die Farben nehmen #hex oder @schlüssel aus der Palette wie der Nach-oben-Knopf; Theme::cssColor() löst beides für beide auf. Ausgegeben wird das als :root{--kant-consent-…} im gehashten Stylesblock (Consent::styles()), die Anordnung als Klasse. Die Form setzt zwei Radien zugleich – Fläche und Knöpfe gehören zusammen; zwei Regler dafür wären zwei, die man gegeneinander verstellt. Leer heißt weiterhin Papier, Tinte und Akzent des Themes.
Was gleich bleibt, mit Absicht. „Nur notwendige“ bleibt ein Rahmenknopf in der Schriftfarbe, genauso groß wie „Alle akzeptieren“, in derselben Zeile – die Farbe des Knopfes ist einstellbar, die Gleichwertigkeit der Antworten nicht. Kein Skript, keine Cookie-Wand.
Archiv und Einwilligungsseite. Beide trugen bisher keine Palette im Kopf. Sie bekommen jetzt Theme- und Hinweisvariablen als gehashten Stylesblock; die Einwilligungsseite erweitert ihre Richtlinie um genau diesen einen Hash.
Der Nach-oben-Knopf. Statt display:none bei offener Frage setzt er sich per Ankerpositionierung auf die Oberkante der Leiste (anchor-name, bottom:calc(anchor(top) + 12px)), wo sie die Ecke abdeckt: bis 799 px bei der Karte, auf jeder Breite bei Leiste und Ecke (:has()). Browser ohne Ankerpositionierung blenden ihn dort weiter aus. Damit die Leiste als Anker taugt, steht sie in page.html.twig vor dem Knopf. Der reservierte Abstand unten wächst auf 320 px bis 620 px, weil die Leiste dort mit Standardtext bis 290 px hoch ist.
ADR 096 – Schriften ausschließlich vom eigenen Server (15.09.2026)
Entscheidung. Die Ausgabe verlinkt kein Stylesheet von fonts.googleapis.com mehr, in keinem Pfad. Liegen die Schriftdateien lokal, liefert die Website sie selbst; liegen sie nicht vor, zeigt sie die eingestellte Ersatzschrift. Theme::googleFontsUrl() entfällt, und die Richtlinien nennen keinen fremden Schrifthost mehr – öffentlich font-src 'self', in der Verwaltung zusätzlich style-src 'self'. Das Herunterladen der Dateien bleibt, wo es hingehört: ein Knopf im Designbereich beziehungsweise cms:fonts:fetch, einmalig und von einem Menschen ausgelöst.
Warum. ADR 037ff. hatten den Verweis auf Google als ausdrückliche Entscheidung Umuts stehen lassen, mit dem Selbsthosten als offenem Weg. Seit die Dateien lokal liegen können, ist der Rückfallpfad nur noch eine Falle: er greift genau dann, wenn niemand hinsieht, und gibt dann die IP-Adresse jedes Besuchers an einen Dritten weiter (LG München I, 3 O 17493/20). Vor allem betraf er die Editorvorschau, die googleFonts und kein localFonts bekam und die Schriften deshalb bei jedem Aufruf von Google holte – auf dem Bildschirm, an dem am meisten gearbeitet wird.
Folge. Eine Installation, deren Schriften fehlen, sieht anders aus als vorher: Ersatzschrift statt Wunschschrift. Das ist beabsichtigt und sichtbar gemacht – der Designbereich nennt die fehlenden Schnitte, und seit derselben Fassung sagt es ein Hinweisstreifen auf jedem Verwaltungsbildschirm mit dem Knopf daneben. Ein stilles „lädt halt von Google“ wäre die schlechtere Überraschung.
Und das Vorabholen. Es holte je Familie die ersten zwei Schnitte. Das Kant-IT-Theme nennt Lato 400, 700 und 900, die Überschriften stehen auf 800/900 und landen damit auf dem einzigen Schnitt ohne Vorabholen: er lud erst nach dem Stylesheet, und font-display:swap tauschte die Überschrift sichtbar. Jetzt wird jeder Schnitt des Themes vorab geholt, im lateinischen Grundbereich, und die Verweise stehen vor dem Stylesheet. Gemessen: alle Dateien sind vor dem ersten Zeichnen da.
ADR 095 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (14.09.2026)
LocalBusiness nannte Name, Adresse, Telefon, E-Mail und Profile – nicht, wann jemand erreichbar ist und wo er arbeitet. Beides sind Angaben der Website, nicht einer Seite, deshalb stehen sie in den Einstellungen: openingHours als Liste {day, opens, closes} je Wochentag (Formular: sieben Zeilen mit Uhrzeitfeldern, leer heißt geschlossen; geprüft als HH:MM, Schließen nach Öffnen) und areaServed als Liste von Ortsnamen (ein Ort je Zeile, höchstens vierzig). organisation() fasst Tage mit gleichen Zeiten zu einer OpeningHoursSpecification zusammen – Mo–Fr 07:30–17:00 ist eine Angabe, nicht fünf – und gibt areaServed als Namensliste aus. Der Text auf der Website bleibt davon unabhängig; niemand soll zwei Wahrheiten pflegen müssen, aber das CMS erzwingt keine.
Eine FAQ ist dagegen Inhalt einer Seite. Ein Akkordeon ist nicht automatisch eine – die Kennzeichnung props.faq (Schema v23, Kästchen „Als FAQ auszeichnen“) macht aus den Einträgen Frage-Antwort-Paare einer FAQPage: Titel als Frage, die Textbausteine des Eintrags ohne Auszeichnungen als Antwort; Einträge ohne Text fehlen. Nur was sichtbar auf der Seite steht, steht auch in den Daten – Google verlangt genau das, und eine erfundene Antwort im Markup wäre der schnellste Weg zu einer Abstrafung. Kein Datenbankwechsel; beide Einstellungen fallen auf leere Listen zurück, das Akkordeon ohne Kennzeichnung bleibt ein Akkordeon.
ADR 094 – Drei Symbole mehr, ein Menü-Knopf mit eigener Gestalt, ein Nach-oben-Knopf ohne Scroll-Animation (14.09.2026)
Symbole. Der Satz hatte vierzehn Zeichnungen und keine für Menschen, Musik und Haus – drei Bilder, die eine Pflege- oder Vereinsseite als erstes braucht. Sie kommen als people, music und home hinzu, gezeichnet im selben Strich, weiter als reine Pfadangabe in blocks/icon.html.twig. Kein Schemawechsel: ICONS ist eine Werteliste, kein Bausteintyp; ein Dokument mit einem neuen Namen ist für ältere Fassungen ungültig, was für Werte dieser Art im Projekt bereits gilt. Ein Test hält Liste und Zeichnungen zusammen, denn ein fehlender Pfad ergibt ein leeres <path d="">: unsichtbar, ohne Fehlermeldung.
Die Farbe des Symbolbausteins. Das eingebettete SVG trägt dieselbe Klasse block-icon wie seine Hülle und setzte damit color:var(--accent) erneut – eine am Block eingestellte Farbe erreichte die Zeichnung nie, und beide bisherigen Nachbauten haben sie mit eigenem CSS überschrieben. .block-icon .block-icon{color:inherit} löst es an der Wurzel und trifft ausschließlich das Paar aus Hülle und Zeichnung; Symbole in Karten, Kontaktangaben und Menüs liegen nicht ineinander und bleiben unberührt.
Der Menü-Knopf. Er ist kein eigener Baustein, sondern der Knopf im Menüblock, und war bisher unveränderlich: Striche plus „Menü“, Rahmen in der Textfarbe, 8 px Ecken. Seine Beschriftung wird zu einer Eigenschaft (burgerLabel: both, text, icon) – „Nur Symbol“ behält den Namen als visually-hidden, denn ein Knopf ohne Namen ist für Vorlesesoftware ein Knopf ohne Zweck. Seine Farben werden zu freien Blockstilen der neuen Gruppe menu mit dem Ziel burger, wie img beim Bild und controls beim Slider: der Compiler schreibt #id .menu-burger>summary{…}. So bleibt die Gestaltung dort, wo jede andere Gestaltung liegt, statt eine zweite Einstellungsart zu erfinden.
Das aufgeklappte Menü im Kopfbereich. Das details klappte inline auf, also innerhalb der schmalen Spalte neben der Marke, und schob den Kopf auseinander; auf dem Telefon sah es aus, als fehle das Menü. Im Kopfbereich – und nur dort, erkennbar an .site-header – liegt die geöffnete Fläche jetzt absolut unter dem Kopf über die volle Breite. Außerhalb des Kopfbereichs bleibt es beim Aufklappen im Fluss, weil ein Menü in einer Sektion keinen positionierten Anker hat, auf den es sich verlassen könnte.
Der Nach-oben-Knopf. Er blendete sich über animation-timeline: scroll(root) ein, abgesichert mit @supports. Ein Browser, der die Eigenschaft kennt, die Zeitachse aber nicht ausführt, besteht die Abfrage und lässt den Knopf im ersten Bild der Animation stehen – bei animation-fill-mode: both heißt das dauerhaft unsichtbar. Statt einer Fähigkeitsabfrage entscheidet jetzt eine Klasse: scrolled.js setzt kant-top-armed sofort und kant-top-shown ab der eingestellten Entfernung, das Stylesheet blendet zwischen beiden Zuständen über. Ohne Skript bleibt der Knopf sichtbar – erreichbar ist besser als unsichtbar –, und das Skript wird jetzt auch geladen, wenn nur der Knopf es braucht.
ADR 093 – Soziale Profile: einmal gepflegt, überall gezeigt (Dokumentschema v23, 14.09.2026)
Ein Link zu Instagram im Fuß, derselbe im Kopf, ein dritter in der Kontaktsektion – bisher wären das drei von Hand gesetzte Symbol-Links, und eine geänderte Adresse hätte drei Stellen zu finden. Deshalb gehören die Profile zur Website, nicht zur Seite: SiteSettings führt unter social je Netzwerk eine HTTPS-Adresse (SocialProfiles::NETWORKS, feste Liste, weil jedes Netzwerk ein gezeichnetes Symbol braucht), gepflegt unter Einstellungen → Unternehmensdaten. Der Baustein social (v23) trägt nur die Darstellung: Größe aus ICON_SIZES als Klasse, Form (Symbol, runde oder eckige Fläche), Anordnung, Beschriftung an oder aus, optional eine Teilmenge der Netzwerke und newTab. Farbe, Abstand und Ausrichtung kommen aus den freien Blockstilen wie überall. Die Vorlage liest die Profile über das Twig-Global siteSettings, darum braucht die Rekursion in block.html.twig keine neue Durchreichvariable. Ohne eingetragene Profile gibt der Block auf der öffentlichen Seite nichts aus und zeigt im Editor einen Hinweis, wo sie einzutragen sind. Die Adressen stehen zugleich in sameAs der strukturierten Daten – dieselbe Aussage, ohne zweite Pflege. Symbole sind eigene Umrisszeichnungen im Strich des vorhandenen Symbolsatzes, keine Markenbilder und keine fremde Datei. Kein Datenbankwechsel: social ist ein Schlüssel im Einstellungs-JSON und fällt auf eine leere Liste zurück.
ADR 092 – Der Kopfbereich kennt einen gescrollten Zustand (14.09.2026)
Der transparente Header aus Flatsome ist zwei Dinge: ein Kopf, der über der ersten Sektion liegt, und Farben, die sich beim Scrollen ändern. Beides bleibt getrennt. Das Schweben ist eine Website-Einstellung (headerOverlay → .site-header.is-overlay, absolut, mit „oben halten“ fixiert), weil es das Layout jeder Seite betrifft. Die Farben sind Gestaltung des Kopfbereichs: neben hover gibt es je Gerät einen Block scrolled, beschränkt auf Farben, Schatten und Deckkraft (StyleSchema::scrollable()) – ein Kopf, der beim Scrollen wächst, würde die Seite unter dem Finger verschieben. Der Compiler gibt html.is-scrolled #id{…} plus einen Übergang aus; ein Skript von zehn Zeilen setzt die Klasse ab 40 px und wird nur geladen, wenn der Kopfbereich solche Werte hat, damit Seiten ohne sie ihre strenge Skriptrichtlinie behalten. Ohne Skript gilt der normale Zustand, eine lesbare Seite. Das Design-Panel zeigt die Zeilen „beim Scrollen“ nur im Kopfbereich, weil sie nur dort etwas bewirken; die Editor-Vorschau setzt die Klasse beim Scrollen selbst, da sie kein Skript ausführt. Kein Schemawechsel: scrolled ist ein optionaler Schlüssel, alte Dokumente bleiben gültig.
Kartenhöhen. Die Stapelabstände .block-iconcard + .block-iconcard (und Zitat/CTA) galten auch im Raster einer Zeile; dort liegt der Rand im Rasterfeld und macht jede Karte außer der ersten um 18 px niedriger. Die Abstände bleiben für Spalten, in .block-row werden sie auf null gesetzt – das Raster hat sein eigenes gap.
ADR 091 – Eine Seitenleiste wie im Flatsome UX Builder; Bewegung spielt, bis die Website etwas anderes sagt (14.09.2026)
Editor. Drei Panels nebeneinander – Werkzeugkasten und Struktur links, Vorschau, Eigenschaften rechts – zeigten immer alles und damit nichts; Umut fand sich nicht zurecht, im Flatsome UX Builder schon. Der Editor übernimmt dessen Rhythmus: eine Seitenleiste, die genau eine Sache zeigt. Reiter für Elemente, Struktur und Seite; ein gewähltes Element öffnet seine eigene Ansicht mit Name, Pfad, den drei Eigenschaftenreitern und den Aktionen am Fuß und gibt sie beim Abwählen wieder frei. Die Reiter bleiben dabei erreichbar, damit Bibliothek und Baum einen Klick entfernt sind. Die Struktur wählt beim Klick nur aus und öffnet erst über „Bearbeiten“; sonst verschwände der Baum bei jedem Schritt. Rechtsklick in der Vorschau öffnet ein Kontextmenü im Editor-Dokument, dessen Position einmal aus Rahmen und Maßstab umgerechnet wird. Tastenkürzel gelten auch, wenn der Fokus in der Vorschau liegt: derselbe Handler hängt am Fenster und an jedem Vorschau-Dokument. Die Auswahl scrollt nur das Vorschau-Fenster, nicht per scrollIntoView – das zog das Editor-Dokument mit und schob die Kopfzeile aus dem Bild. Die verstellbaren Panelbreiten entfallen; eine feste Leiste ist der einfachere Vertrag. Bestehende IDs (#library, #structure, #properties, #preview, #save-status …) bleiben, die Browserprüfung wechselt die Ansicht über Helfer statt über versteckte Elemente zu klicken.
Bewegung. Bisher entschied allein die Browservorgabe prefers-reduced-motion, und die meldete Umuts Chrome als aktiv, ohne dass er sie gesetzt hatte; andere Websites bewegen sich bei ihm, weil sie die Vorgabe ignorieren. Die Entscheidung gehört der Website: Einstellung motion mit always (Standard, Flatsome-Verhalten) und respect. Der Server druckt bei respect die Klasse motion-respect auf <html>; motion.js und slider.js fragen Klasse && Vorgabe, das CSS schreibt die Hover-Fahrten unbedingt und nimmt sie in einem Überschreibblock @media (prefers-reduced-motion: reduce) { html.motion-respect … { transform: none; transition: none } } zurück – Farben und Schatten bleiben, nur die Fahrt fällt weg. Einblendungen verstecken einen wartenden Block per Inline-opacity:0 (nur mit Skript, nur wenn Bewegung erlaubt ist) und blenden ihn mit fill: backwards von unsichtbar ein; ein scroll-Handler zeigt sofort, was oberhalb des Bildes liegt, weil ein Sprung per Sprungmarke oder Wiederherstellung sonst Blöcke dauerhaft unsichtbar ließe. Beim Abschalten (Präferenzwechsel unter respect, Editor-Stopp) werden alle versteckten Blöcke wieder eingeblendet. Barrierefreiheit: Einblendungen dauern unter einer Sekunde, blinken nicht und lassen sich per Einstellung an die Vorgabe binden; Lighthouse misst diese Vorgabe nicht. Kein Schema- und kein Datenbankwechsel: motion fällt bei fehlendem Wert auf always zurück.
ADR 090 – Releasecache bleibt für laufende Updateanfragen erhalten (14.09.2026)
Der echte HTTP-Updatepfad von 1.10.0 nach 1.10.1 reproduziert Umuts 500 auf /admin/system/archiv: Die Dateien sind bereits erfolgreich ersetzt, dann entfernt ArchiveUpdate den gesamten var/cache. Symfony lädt beim Abschluss weitere Dienste aus dem kompilierten Container nach und findet deren PHP-Dateien nicht mehr. Eine frische Installation und isolierte Updater-Rückgabewerte decken diesen Fehler nicht ab.
Der Kernel verwendet deshalb var/cache/<Umgebung>/release-<SHA256-Präfix von VERSION>. Er merkt sich diesen Pfad pro Kernelinstanz, damit das Überschreiben von VERSION während der Anfrage ihren Cachepfad nicht ändert. Der Archiv-Updater löscht keinen Cache mehr; die folgende Anfrage startet mit dem eigenen Container- und Twigcache der neuen Version. Alte Cacheverzeichnisse bleiben absichtlich erhalten. Sie können bei gestoppter Anwendung entfernt werden, nicht während eines laufenden Updates. Keine Datenmigration; ein Downgrade verwendet wieder den zu seiner Fassung gehörenden Cache und kann ihn bei Bedarf über die Konsole erneuern.
Übergang: Ein bereits installierter Updater bis einschließlich 1.10.1 führt noch seinen alten Löschcode aus, auch wenn er 1.10.2 installiert. Dieser Updateaufruf kann einmalig weiterhin mit 500 enden. /admin/system direkt öffnen und die angebotenen Migrationen abschließen; die Marke in var/update/offen.json erhält den Fortschritt. Die Reparatur verhindert den Abbruch bei nachfolgenden Updates aus dem korrigierten Stand. Kein Erfolg der Produktionsmigrationen wird ohne Prüfung behauptet.
Regression: scripts/archive-update-check.mjs installiert zwei vollständige Releasearchive in einem eigenen Verzeichnis und prüft den echten HTTP-POST, Wartungszustand, Weiterleitung, erhaltene Sitzung, Systemseite, Migrationsabschluss, Sicherung und öffentliche Ausgabe. Nur der Downloadtransport wird auf das lokale Zielarchiv umgestellt. UPDATE_SOURCE_VERSION erlaubt eine ausdrücklich synthetische vorherige Versionsnummer auf dem korrigierten Paket, um dessen nächsten Updateaufruf vor einem weiteren Release zu prüfen. Die Original-Pilotdatenbank bleibt unverändert.
ADR 089 – Formularaktionen nutzen den Website-Standardbutton (14.09.2026)
Der öffentliche Absenden-Button erhält dieselbe Klasse cms-button wie normale Buttonblöcke. Die bisherigen formularspezifischen Farb- und Hover-Regeln entfallen; die bereits vorhandenen Theme-Variablen bestimmen Form, Darstellung, Größe, Schrift und Farben. justify-self:start verhindert das implizite Strecken durch das Formular-Grid. Es werden keine Designwerte in Formulardefinitionen kopiert: spätere Änderungen am Website-Standard wirken auch auf vorhandene Formulare. Nativer Submit-Typ, Beschriftung, Fokus, Validierung und Versand bleiben erhalten. Keine Daten-/Schemamigration; ein Code-Rückweg stellt das frühere Sonderdesign wieder her.
ADR 088 – Gerätesichtbarkeit und automatische Hamburger-Navigation (14.09.2026)
layout.visibility enthält optional eine feste Auswahl: all, desktop, tablet, mobile, hide-desktop, hide-tablet, hide-mobile, hidden. Fehlend bedeutet überall sichtbar. Die Bedienidee wurde anhand der lokalen Flatsome-Dateien inc/builder/shortcodes/commons/visibility.php und template-parts/header/header-main.php geprüft; kein Fremdcode oder Asset wurde übernommen. Die Sichtbarkeit steht in allen Eigenschaftsansichten direkt am Element und wird im Strukturbaum benannt. Ausgeblendete Elemente bleiben dort auswählbar. Änderungen verwenden dieselbe History, Entwurfsspeicherung und Veröffentlichung wie andere Blockwerte.
Vorschau und Öffentlichkeit verwenden dieselben CSS-Klassen. display:none!important entfernt ausgeblendete Elemente aus Layout, Tastaturreihenfolge und Accessibility Tree, ohne ihre eigenen Flex-/Grid-/Display-Werte umzuschreiben. Die Grenzen folgen dem bestehenden freien StyleCompiler: Mobil bis 767 px, Tablet 768–1024 px, Desktop ab 1025 px. Die Sichtbarkeit eines Elternbereichs gilt damit auch für seine Kinder. Wrapper von Logos, Tablinks und Sliderfolien erhalten dieselbe Auswahl; Tabs und Slider überspringen ausgeblendete Einträge bei der Bedienung. Sichtbarkeit entfernt Inhalte nicht aus dem HTML und ist keine Zugriffskontrolle.
Waagerechte Menüblöcke wechseln standardmäßig bis 1024 px sowie bei hover:none und pointer:coarse zum Hamburger. Das umfasst auch große iPads ohne Maus bei mehr als 1024 CSS-Pixeln; es gibt keine User-Agent-Erkennung. props.collapse erlaubt tablet (Standard), mobile (nur bis 767 px) und never. Bewusst vertikale Menüs bleiben vertikal, explizite Burger bleiben aufklappbar. Die Automatik wirkt auch auf bisherige waagerechte Menüs ohne neue Speicherung. Bereits separat angelegte Menüblöcke bleiben eigenständige Elemente und können mit der neuen Sichtbarkeit zugeordnet werden.
Die aufgeklappte Navigation verwendet native details/summary und benötigt kein öffentliches JavaScript. Der gemeinsame Menübaum wird für die breite und die aufklappbare Darstellung ausgegeben; CSS zeigt genau eine Variante, die andere fehlt auch in der Fokusreihenfolge. Das kostet etwas zusätzliches HTML, vermeidet aber eine skriptabhängige Navigation. Der Summary-Name bleibt „Menü“, der Browser vermittelt den Öffnungszustand. Im Editor wird der Öffnungszustand nur in der Vorschau umgeschaltet und nicht gespeichert.
Migration: Schema 22 wird beim ersten Setzen einer neuen Sichtbarkeits- oder Hamburger-Option verwendet. Bestehende Dokumente bleiben unverändert lesbar; keine SQL-Migration. Der Validator weist unbekannte Werte und neue Optionen in alten Schemata ab.
Rückweg: Vor einem Downgrade sichern. Schema-22-Dokumente exportieren, rekursiv layout.visibility und bei Menüs props.collapse entfernen, schemaVersion auf 21 setzen, mit dem alten Validator prüfen und importieren; alternativ die vorherige Sicherung wiederherstellen. Damit entfallen die neuen Gerätebeschränkungen und die automatische Umschaltung. Layout, Inhalt und stabile IDs bleiben erhalten.
ADR 087 – Button-System, benannte Sprungziele und Menübedienung (14.09.2026)
Globale Button-Vorgaben liegen optional unter theme.buttons; Vorschau und öffentliche Ausgabe verwenden dasselbe kleine CSS-System mit Geräteklassen. Lokale Varianten stehen getrennt von Inhalt und freien Styles in layout.button.<Gerät>. Vorlagen ändern Form/Darstellung, erhalten Text, Link und Abstände und entfernen kollidierende freie Werte nur für die betroffene Einstellung am aktiven Gerät. Verbleibende eigene Styles können Varianten übersteuern. Die Bedienidee orientiert sich an der lokal bereitgestellten Flatsome-Button-Konfiguration (inc/builder/shortcodes/button.php); Implementierung und Assets wurden nicht übernommen.
Benannte anchor-Werte sind nur an Sektion, Zeile und Spalte erlaubt und werden gegen alle Block-IDs, andere Ziele und reservierte IDs geprüft. Ein zusätzliches Zielelement erhält den Namen; die stabile Block-ID bleibt erhalten. Duplizieren entfernt benannte Ziele aus der Kopie. Native Fragmentlinks funktionieren ohne öffentliches JavaScript.
Menüeinträge speichern optionale icon- und iconPosition-Werte aus der bestehenden SVG-Allowlist. Drag-and-drop verwendet denselben Baum und Undo-/Speicherweg wie Tastaturbefehle; Unterpunkte reisen mit dem Hauptpunkt. Die Versionsanzeige liest nur das vorhandene Ergebnis des täglichen Update-Workers. Dadurch entstehen beim Öffnen von Adminseiten keine zusätzlichen externen Anfragen.
Migration: Validator und Editor unterstützen Schema 21 zusätzlich zu bisherigen Fassungen. Erst eine neue Button-Variante oder ein Sprungziel hebt das bearbeitete Dokument auf 21; vorhandene Dokumente werden nicht pauschal umgeschrieben. Globale Buttons und Menü-Icons sind optionale Konfigurationsfelder, keine SQL-Migration.
Rückweg: Vor einem Downgrade eine Sicherung erstellen bzw. die Sicherung vor 1.9.0 wiederherstellen. Alternativ Schema-21-Dokumente exportieren, anchor und layout.button rekursiv entfernen und die Fassung auf 20 setzen, anschließend mit dem alten Validator prüfen/importieren. Alte Software kennt die neuen Konfigurationsfelder nicht; globale Button-Vorgaben/Icons gegebenenfalls aus der Sicherung entfernen, sie können beim alten Speichern verloren gehen. Sprunglinks auf entfernte Ziele müssen angepasst werden.
ADR 086 – Bildausrichtung berücksichtigt Rahmen, Bild und Layoutachse (14.09.2026)
align-self allein hat in normalen Inhaltsbereichen keine Wirkung. Ein prozentual schmaler Bildrahmen blieb links; bei voller Rahmenbreite konnte zusätzlich das kleinere Bild im Rahmen links stehen. Der Browserfall mit einem 50-Prozent-Bild in einer gepolsterten Spalte reproduzierte die Abweichung trotz ausgewählter Mitte.
Der gemeinsame StyleCompiler ergänzt bei Bildern mit eigener Ausrichtung horizontale Außenabstände am Rahmen und am enthaltenen Bild sowie justify-self für Raster. Verlinkte und vergrößerbare Bilder werden über dasselbe innere Bild erreicht. In horizontalen Flex-Gruppen bleiben die Außenabstände am Rahmen erhalten: Dort wirkt die vorhandene Einstellung auf der vertikalen Querachse und soll die horizontale Verteilung nicht verändern. Sektionen, Akkordeon-Einträge und Slider haben innere Inhaltswrapper, deren Blockfluss berücksichtigt wird.
Bild- und Elterneinstellungen werden je Gerät geerbt. Änderungen der Flexrichtung werden deshalb auch bei unverändertem Bildwert wirksam. Auto, Strecken und Grundlinie entfernen die zusätzliche horizontale Bildausrichtung und stellen die jeweiligen selbst gesetzten Außenabstände wieder her. Originalmaße, Bildquellen, Bildbreite, Beschriftungen und Links werden nicht umgeschrieben. Beliebiges benutzerdefiniertes CSS kann diese Layoutannahmen weiterhin überschreiben.
Migration und Rückweg: Keine Änderung am Dokument- oder Datenbankschema. Bestehende gespeicherte Ausrichtungswerte werden beim Rendern korrigiert, ohne erneutes Speichern. Vorschau und öffentliche Ausgabe verwenden denselben Compiler; kein zusätzliches öffentliches JavaScript. Ein Rückweg auf 1.8.3 erhält alle Daten, zeigt jedoch dessen bisherige Ausrichtung.
ADR 085 – Akkordeon-Inhalte sind im Editor direkt zugänglich (14.09.2026)
Die bisherige Vorschau verhinderte Standard-Klickaktionen einschließlich des Öffnens von <details>. Einträge mit props.open: false blieben deshalb geschlossen; für leere Akkordeon-Einträge fehlten zusätzlich die Einfügehilfen. Ein Browserfall reproduzierte den geschlossenen Zustand nach Anklicken des Titels.
preview-containers.js öffnet Akkordeon-Einträge ausschließlich im Vorschau-DOM. Leere Einträge zeigen eine Einfügefläche, gefüllte Einträge eine Schaltfläche zum Anhängen weiterer Inhalte. Eine eigene Aktion am Akkordeon legt Einträge an, auch wenn es vollständig leer ist. Die rechte Inhaltsansicht listet Einträge mit ihren Titeln und bietet dieselben Aktionen. Neu eingefügte Akkordeons wählen ihren ersten Eintrag und den Inhaltsbereich; Struktur und Struktursuche berücksichtigen Titel.
Die Modellauswahl eines Akkordeons erlaubt direkt nur neue Einträge. Texte und andere Inhalte erfordern die Auswahl des Eintrags, damit sie nicht unbemerkt außerhalb des Akkordeons landen. Der gesonderte Befehl für neue Sektionen auf Seitenebene bleibt erhalten. Explizite Einfügepunkte und Drag-and-drop verwenden weiterhin die bestehenden Eltern-/Kindregeln; „Inhalt hinzufügen“ hängt innerhalb des angesprochenen Eintrags an.
Migration und Rückweg: Kein Schemawechsel und keine Datenmigration. Öffnungszustand, Kinder, IDs und Reihenfolge bleiben normale Blockwerte. Vorschauhilfen werden nie gespeichert; die öffentliche Ausgabe und deren native Tastatur-/Mausbedienung bleiben unverändert. Älterer Code kann die Daten weiterhin lesen, zeigt jedoch wieder die bisherige eingeschränkte Vorschau. Alle Inhaltsänderungen laufen über den vorhandenen Undo-/Speicher-/Veröffentlichungsweg.
ADR 084 – Visuelle Palettenauswahl im Editor (14.09.2026)
Alle Farbfelder des Designbereichs verwenden color-control.js: kompakte Vorschau mit Farbnamen und aufgelöstem Hexwert, aufklappbare Farbkacheln, nativer Farbwähler und validierte Hexeingabe. @key wird nur für die Darstellung aufgelöst; die gespeicherte Palettenreferenz bleibt erhalten. Gerätespezifische Vererbung und Hover-Werte größerer Geräte sind sichtbar. Ohne zuordenbaren Farbwert zeigt das Feld ein neutrales Schachbrett und „Übernehmen“; es erfindet keine schwarze Farbe. Achtstellige Hexwerte behalten ihren Alphakanal auch beim Ändern des Farbtons. Dreistellige Hexeingaben werden vor dem Speichern in sechsstellige Werte umgewandelt.
Palettenwahl und bestätigte Texteingaben sind einzelne Undo-Schritte. Eine Bewegung im nativen Wähler aktualisiert die Vorschau mit einem gemeinsamen History-Schlüssel und erhält den DOM-Knoten bis zum Abschluss. Danach werden Gerätemarkierungen, Reset und Kontrasthinweis aktualisiert. Farbauswahl per Tastatur, Escape, Fokuswiederherstellung und lokale Fehlermeldung sind Teil desselben Bedienelements. Die Farbkacheln verwenden beschriftete Buttons mit Auswahlzustand; lange Paletten bleiben innerhalb des Panels scrollbar. Keine zusätzliche Bibliothek und kein öffentliches Skript. Das neue Modul steht in der versionierten Importmap.
Migration: Keine Änderung an Datenbank oder Dokumentformat. Bereits erlaubte sechs-/achtstellige Hexwerte und Palettenreferenzen werden unverändert gespeichert. Ein Rückweg auf 1.8.1 zeigt dieselben Werte im früheren Farbfeld. Verwaltungsformulare außerhalb des visuellen Editors behalten ihre bisherigen Felder.
ADR 083 – Footer-Links werden redaktionell platziert (14.09.2026)
Auf ausdrücklichen Wunsch von Umut entfällt die automatisch angehängte Rechtszeile vollständig. Seiten, Beiträge, Archive, Fehlerseiten und Editorvorschauen geben ausschließlich die selbst platzierten Footer-/Inhaltslinks aus. Die frühere Erkennung des speziellen legal-Blocks konnte normale Text- oder Menülinks nicht berücksichtigen; dadurch erschien unter einem vollständig gestalteten Footer ein zusätzlicher heller Streifen mit doppelten Links. Die Template-Ausgabe, ihre Erkennung im Controller und ungenutztes CSS sind entfernt.
Normale Links, Menüs und bewusst eingefügte „Rechtliches“-Blöcke bleiben erhalten. Der optionale Block nutzt weiterhin die konfigurierten Seitenadressen und den Link zur Einwilligung. Einwilligungsdialog, Einstellungen und der Endpunkt /einwilligung behalten ihre Funktion. Keine Inhalte werden umgeschrieben, keine neue Schema- oder Datenbankmigration. Bei einem Rückweg auf ältere Versionen erscheint deren automatische Leiste wieder.
Diese Entscheidung ersetzt die automatische Verlinkung aus ADR 033 und die bedingte Zusatzleiste aus ADR 062; Position und Gestaltung der Links liegen beim Autor.
ADR 082 – Zuweisbare 404-Seite im gemeinsamen Seitenrenderer (14.09.2026)
website.settings.notFoundPageId verweist auf eine normale Seite über deren stabile ID. Eine eigene Einstellung mit Livesuche weist sie zu oder entfernt die Zuordnung; sie benötigt Veröffentlichungsrechte und CSRF-Schutz. Der erwartete bisherige Wert schützt gegen ein veraltetes Formular. Die Änderung liest die aktuellen Einstellungen innerhalb einer Transaktion, unter MySQL/MariaDB mit Zeilensperre, und schreibt nur die geänderte Zuordnung in den erhaltenen JSON-Inhalt. Das normale Unternehmensformular erhält diesen Wert unabhängig von seinen eigenen Feldern. Startseite und 404-Seite dürfen nicht dieselbe Seite sein.
Ohne verwendbare veröffentlichte Zuordnung wird ein v10-Blockdokument im Speicher erzeugt. Öffentliche GETs legen keine Seiten an. „404-Vorlage anlegen“ erstellt eine bearbeitbare Kopie als Entwurf und weist sie zu; bis zur Veröffentlichung bleibt die Standardausgabe sichtbar. Vorhandene Adressen einschließlich Papierkorb werden erhalten; bei belegtem /404 wird /404-2 usw. gewählt. Wiederholtes Anlegen überschreibt keine Seite. Die erzeugte Vorlage erhält noindex. Erstellen und Zuweisen laufen in einer gemeinsamen Datenbanktransaktion.
Normale Seiten, zugewiesene Fehlerseiten und der Standard verwenden denselben Renderer mit Theme-CSS, Schriften, Standard-Header/-Footer, Menüs, verknüpften Sektionen und Einwilligungsanzeige. Auf der Fehlerausgabe haben die Standardbereiche Vorrang vor eventuellen Bereichsabweichungen der ausgewählten Seite. Nur veröffentlichte Revisionen sind öffentlich sichtbar; Umbenennen erhält die Zuordnung, Löschen/nicht veröffentlichte Seiten führen auf den Standard zurück. Die Originaladresse bleibt stehen, die Antwort trägt HTTP 404 und noindex, follow in HTML und Header, ohne Canonical oder strukturierte Daten einer normalen Seite. Auch die eigene Adresse der zugewiesenen Seite antwortet mit 404. Sitemap und llms.txt schließen die zugewiesene ID zusätzlich aus, unabhängig von deren normalen Metadaten.
Ein schmaler Exception-Subscriber übernimmt öffentliche HTML-GET/HEAD-Routingfehler außerhalb der Slug-Grammatik, etwa alte .html-Adressen oder tiefere Pfade. Fehler bestehender Controller, Verwaltung, Medien/Assets, Formulare, Installation, AJAX sowie maschinenlesbare Ressourcen bleiben ihren bisherigen Endpunkten überlassen. Normale Seiten- und Archivfehler rufen den Fehlerrenderer direkt auf. 301/410 werden vorher behandelt; 410 nutzt einen eigenen Standardtext und behält seinen Status.
Migration und Rückweg: Keine Datenbankmigration und kein neues Dokumentschema. Fehlender/null Settings-Inhalt erhält beim Lesen den leeren Standardwert. Die Einstellung wird erst beim Zuweisen gespeichert. Die Vorlage nutzt bestehende v10-Blöcke. Älterer Code ignoriert die neue Zuordnung und verwendet wieder seine bisherige 404-Ausgabe; die angelegte Seite und ihre Revisionen bleiben normaler Inhalt. Vor einem Rückweg die Website-Einstellungen sichern, da ältere Einstellungsformulare unbekannte JSON-Schlüssel entfernen können. Kein automatisches Veröffentlichen oder Überschreiben vorhandener Seiten.
ADR 081 – Linkziel und sichtbare Medienauswahl (14.09.2026)
Schema v20 ergänzt optionales props.newTab: boolean an Button, Bild, Symbolkarte, Aufruf-Band und Symbol. Fehlend/false öffnet wie bisher im aktuellen Tab; true erzeugt im gemeinsamen Renderer target="_blank" rel="noopener". Derselbe Wert ist als optionales Feld in Menüeinträgen verfügbar; die bestehende Menüversion schützt weiterhin vor veralteten Schreibzugriffen. Der Rich-Text-Filter erlaubt ausschließlich _blank als Ziel und setzt noopener selbst. Andere Targets und fremde Attribute werden entfernt. Textlinks bleiben Teil des bisherigen HTML-Vertrags. Bei Bildern entscheidet die gewählte Klickaktion; eine hinterlegte Adresse bleibt bei „Nichts tun“ gespeichert, erzeugt aber keinen Link mehr.
Bilder, Galerie-/Logobilder und Hintergründe zeigen kleine Vorschaubilder mit Lade-/Fehlerzustand. Für Hintergrund-IDs liefert eine authentifizierte einzelne Medienabfrage die tatsächlich vorhandene Bildvariante; auch kleine Originale und archivierte, weiterhin verwendete Dateien funktionieren. Die Bildauswahl verwendet denselben Vorschaucache. Späte Antworten ersetzen keine neuere Auswahl. Entfernen eines Inhaltsbildes löscht dessen Block aus dem Entwurf, ein Galerieeintrag bleibt eine einzelne Änderung; beide sind rückgängig zu machen. Die Mediendatei bleibt erhalten. Bildaustausch ersetzt Quelle und Bildmetadaten, erhält Link, Bildunterschrift, Block-ID und Gestaltung.
Ein Hintergrund wird mit style.<device>.backgroundImage: "none" ausdrücklich ausgeblendet. Das ist von Zurücksetzen zu unterscheiden: Zurücksetzen entfernt den eigenen Wert und stellt Vererbung her. Der Compiler übernimmt Bild, Verlauf und Überlagerung schrittweise Desktop→Tablet→Mobil; dadurch erscheint ein auf Tablet entferntes Bild nicht auf Mobil wieder. Geerbte Verläufe bleiben beim Entfernen des Bildes erhalten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Erzeugung. Die neuen Bedienelemente gehören nur zum Editor; kein zusätzliches öffentliches JavaScript.
Migration: Dokumente v1–19 bleiben lesbar und werden nicht beim Laden umgeschrieben. Das Editor-Modell hebt die Version erst beim Setzen eines neuen Block-Linkwerts oder eines ausdrücklichen Hintergrund-„none“ auf v20 an, einschließlich der bisherigen Schema-Upgrades. Die Validierung verlangt für diese Werte v20. Keine Datenbankmigration. Rückweg auf ältere Software: vor Bearbeitung mit v20 angelegte Revision/Export oder die Sicherung vor dem Update wiederherstellen; ältere Validatoren können v20 nicht speichern. Menüs benötigen ebenfalls die Sicherung, falls neue Tab-Einstellungen auf älterem Code erhalten bleiben sollen. Kein stilles Herabsetzen der Dokumentversion.
Korrektur 1.7.4: Native Inhalts-Auswahlfelder verarbeiten input und change, identische aufeinanderfolgende Werte werden nur einmal übernommen. Jede Auswahl ist ein eigener Undo-Schritt. Die Bild-Klickaktion ersetzt beim Wechsel nicht mehr den ganzen Feinschliff: Linkziel und Tab-Häkchen werden in einer bestehenden Gruppe ein-/ausgeblendet. Das erhält Dropdown, Fokus und eingegebene Adresse. Ein Test mit ausschließlich change konnte das fehlende URL-Feld vor der Korrektur reproduzieren; native Auswahl und Speicherung werden zusätzlich geprüft. Keine Änderung des Datenvertrags.
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)
Ergänzung zur Laufzeit: Eine Änderung von reduzierter zu erlaubter Bewegung nimmt die Beobachtung noch nicht gezeigter Elemente wieder auf, auch wenn das Dokument schon mit reduzierter Bewegung gestartet ist. Die Aufräumfunktion entfernt denselben Präferenzlistener. Im Editor zeigt eine laufend aktualisierte Statusmeldung die tatsächlich gemeldete Browserpräferenz. Das CMS setzt diese weder aus einer Windows-Einstellung noch aus erfolgreichen Testprofilen voraus; es überschreibt sie nicht. Die Browsermeldung und ihre Ursache sind getrennt zu diagnostizieren.
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, Absender (seit 1.15.1 wählbar, leer heißt Empfänger – manche Hoster stellen nur zu, was von einer Adresse der eigenen Domain kommt), 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.
Schriften von Google, aber nicht zur Laufzeit. Eine Familie lässt sich weiterhin bei Google aussuchen; ausgeliefert wird sie aber ausschließlich vom eigenen Server. Die Dateien holt ein Knopf im Designbereich einmalig her, und die Ausgabe verlinkt nie ein fremdes Stylesheet – Näheres in ADR 096.
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.
Nachtrag 23.09.2026: content mit Text. Werte durften bisher keine Anführungszeichen enthalten, damit gab es kein ::before oder ::after – beim Nachbau von Kantarci Markt fehlten dadurch der Verlauf über einem Foto, ein Goldstrich und ein Buchstabe als Wasserzeichen. Jetzt nimmt content einen Text in einfachen oder doppelten Anführungszeichen an, höchstens 40 Zeichen aus Buchstaben, Ziffern, Leerzeichen und .,:!?·•–-. Kein <, kein Backslash, kein zweites Anführungszeichen: Der Wert kann weder den String noch das <style> verlassen. Jede andere Eigenschaft bleibt ohne Anführungszeichen (also auch kein font-family:'…'). Folge: Dekoration, die Vorlesesoftware nicht vorlesen soll, gehört in ein Pseudo-Element statt in einen Textbaustein.
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 quadratischer Ausschnitt in 32 px (Reiter), 180 px (Startbildschirm) sowie 48 und 192 px (Google nimmt nur Vielfache von 48), 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, /favicon-192.png, /favicon.ico (48 px) und /apple-touch-icon.png, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt. Auch /favicon.svg beantwortet die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen, mit eigenem ein 302 auf das 192er – als Datei in public/ hätte der Webserver immer das mitgelieferte Zeichen ausgeliefert, und Suchmaschinen hätten es behalten.
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 (seit 1.7.0: .env, siehe ADR zur Umgebungsdatei), 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.