# Architekturentscheidungen Stand: 09.10.2026. Arbeitstitel KantSites; Produktname offen. ## 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//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 `` 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.` nur Texte, adressiert über die stabile Block-ID: `{slug?, title?, description?, seoTitle?, blocks: {: {: text}}, source: {.: 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/` 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 ``, nur Pfade aus der Zuordnung der Sprache). Links der Sprachwahl tragen `hreflang` und bleiben unberührt. **SEO.** ``, `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.`: 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-{--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 (``): 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 `